Compare commits
468 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02193967de | ||
| 5f37e862c9 | |||
| 9925bbdb48 | |||
|
|
1f7ca4813c | ||
| ffc871ebbd | |||
|
|
7b9a36403d | ||
| 09c6c93c39 | |||
| c31e9a3aff | |||
|
|
960d84b7fe | ||
| e6f204b6aa | |||
| 02239de0a3 | |||
|
|
0aad9a0988 | ||
| c5501860e8 | |||
| 4e5520aee2 | |||
| 4591ba8f73 | |||
| f335763280 | |||
| e890091d86 | |||
| ac2cb5197d | |||
|
|
65345187b3 | ||
| d48243483e | |||
| 1ca9499edd | |||
| 060935ffc5 | |||
|
|
50dbef52c0 | ||
| bb385f07ca | |||
| cf28730515 | |||
|
|
13ae383455 | ||
| 2265458dcc | |||
|
|
0a59f08fcc | ||
| c70724a456 | |||
| 406c263746 | |||
| df35aabff3 | |||
| cc8c166b5c | |||
| c4f3308dc0 | |||
|
|
8f3824c0e7 | ||
| afdf4e8782 | |||
| 2a82032644 | |||
| 88a2f66758 | |||
| 3f3b207295 | |||
| 44cfda1c07 | |||
| e42b84c636 | |||
| 77c5aa741c | |||
| 2b4449afeb | |||
| 36ad464159 | |||
|
|
e8ae6f2e43 | ||
| 3ecbd60627 | |||
| 82a55ddf3e | |||
|
|
7d190719b1 | ||
| 8c2e7c8259 | |||
| dd7c71bb1e | |||
|
|
7b5b7a8cbb | ||
| af28574bd5 | |||
| 617db36ed4 | |||
|
|
ebc2e44c7c | ||
| 44738057a3 | |||
| f98a9f9771 | |||
| 2fe72c9ccb | |||
| f0203d9bf6 | |||
| 37835cbf76 | |||
|
|
e005be33d1 | ||
| 9d7718c3d9 | |||
| 9d8fb0b761 | |||
|
|
9df1e0899b | ||
| 640464a654 | |||
| 84abe46050 | |||
| 1d2afaa09e | |||
| 2bf5c7096e | |||
|
|
41dc6e6cfd | ||
| 650039303a | |||
| 196504b533 | |||
| 2c31cc90ae | |||
| e870e5ba08 | |||
| 73f5a2f085 | |||
| 4790afde3d | |||
| 7357f3d2a1 | |||
| e9ecd268c6 | |||
| 91ba30e8d0 | |||
|
|
d36d801ef1 | ||
| 939f834a26 | |||
|
|
bee51bd86e | ||
| bc2abe945f | |||
|
|
49a5a23d41 | ||
| 4f96d0e4a1 | |||
| ea9240d2f7 | |||
| 4d02b42f11 | |||
|
|
9509be14be | ||
| 198c1d1064 | |||
| 2af5c94913 | |||
|
|
a4a0bac3c1 | ||
| f285b35b49 | |||
| 7aeb2b5c26 | |||
| d56ea95ef9 | |||
|
|
5733fea98c | ||
| 98b79aac7b | |||
|
|
4212fe0e32 | ||
| 93d397759c | |||
|
|
8c5b901a37 | ||
| 0273bf4856 | |||
| c80a7cd108 | |||
|
|
a50d9c7b3f | ||
| 281633deff | |||
| 0d190c5c59 | |||
| 6269009e54 | |||
| 6d2442d23c | |||
|
|
110b27351b | ||
| 37aa371e7c | |||
| eb54e9f788 | |||
|
|
482efeb340 | ||
| 99ee545e41 | |||
| cf94599c25 | |||
| b50b3a27e6 | |||
| bf6294ecbf | |||
| a3d4f5ac4b | |||
| bc264975b1 | |||
| ad07bbf85e | |||
| 9856857f4c | |||
| f9e5897900 | |||
|
|
39fb22b716 | ||
| a372925fff | |||
|
|
ec54440569 | ||
| af86860bf3 | |||
| 302ae90139 | |||
|
|
1405068925 | ||
| 5aad401ef8 | |||
|
|
885dcfda89 | ||
| 30fef929cf | |||
| 1f30dd73a9 | |||
| 73cd11e472 | |||
| 7616ca0e14 | |||
|
|
ca29a69779 | ||
| dcc5fd71ee | |||
|
|
fee4901657 | ||
| 71873ddf35 | |||
|
|
f8552ca551 | ||
| 995a795060 | |||
|
|
7ab81c5797 | ||
| bc1e23944c | |||
| a3fe20500a | |||
| 61a4e32deb | |||
| 3d681f77e1 | |||
| 5a9ccfd1f6 | |||
| fc57b7a126 | |||
| 06205e0790 | |||
| 4be6fd6b83 | |||
| 714e1e139e | |||
|
|
01c0e0b1df | ||
| 4457ef2147 | |||
| 8ca60d54b3 | |||
| 5696c993dc | |||
| 1206e15309 | |||
|
|
4f2b51b211 | ||
| 1b9c55a46a | |||
| f4844d2e06 | |||
| 06fab0eab9 | |||
| a16b87ac28 | |||
| cce1367a72 | |||
| 28f26e92a4 | |||
|
|
2f5cc3030d | ||
| 1cf6e32303 | |||
| 7f49893d2c | |||
| ba0d1ea903 | |||
| 70fb276fdf | |||
| 43711680ba | |||
| 3d2ca4855c | |||
| fe7e542b19 | |||
| 501eb923f1 | |||
| c15035b6b7 | |||
| 6a9317facd | |||
|
|
95e515114a | ||
| fd6ae91993 | |||
| 3798714369 | |||
|
|
067496b18c | ||
| ad112d1f08 | |||
| a3dff7decc | |||
| 2bcaa4256d | |||
|
|
006f7c01fd | ||
| 2c8764a27d | |||
| 50135b5fe9 | |||
| 8d764e2d46 | |||
| 6eb313fa76 | |||
| 1f8ef52b60 | |||
| 2be009c647 | |||
| 8df6b003e5 | |||
| 7089cf356a | |||
| 1e551d6e96 | |||
| 8e588d79c8 | |||
| 8d93405399 | |||
| 6ff6111091 | |||
| a8b6ef20cc | |||
| a8ff1d4cd0 | |||
| 47fcb9ebfe | |||
| e8305652fd | |||
| 33495cfe03 | |||
| 8ac35d7280 | |||
| c926a75a79 | |||
| fa9b17191d | |||
| 755b394c1c | |||
|
|
717df63d62 | ||
| d75c55b2b1 | |||
| e52ee2604c | |||
| c7feb6952d | |||
| d64758f268 | |||
| 6202d224fe | |||
|
|
4ac90e1e56 | ||
| 8f104cf402 | |||
| 787f74949b | |||
| 196ef7afe1 | |||
|
|
18ac3ffac0 | ||
| ba69e7957c | |||
|
|
5acb47532c | ||
| b5b0aa4f82 | |||
|
|
9a91583ed0 | ||
| b98fd00ade | |||
|
|
d5c5e12589 | ||
| e495fd30c4 | |||
| 8516a1d639 | |||
| 006992e43c | |||
| 48911e9348 | |||
| e4e1a905d1 | |||
| fc935d9fc8 | |||
| 0c6a9f2310 | |||
| d23fd8bd07 | |||
| 9d6ae87d0f | |||
| fc5a8bdd8b | |||
| b8717f1327 | |||
| 0aa317aae5 | |||
|
|
edc19bdff8 | ||
| 11a7204c98 | |||
| eab7883979 | |||
| 2d4249e73a | |||
| 63db1352ee | |||
| 8308115f36 | |||
|
|
02fce838db | ||
| 360d171355 | |||
| eb26e2a11b | |||
| 2b29e34b52 | |||
| fd8766ed87 | |||
| 5de8804da1 | |||
| 2988fd387e | |||
| 1b017edfad | |||
| 903ce7d46b | |||
| 41bcb80167 | |||
|
|
d3f85060ca | ||
| 90178e2f61 | |||
|
|
b9f9a003a2 | ||
| 734f4c7750 | |||
| c78cd898f2 | |||
| 74a249bd06 | |||
| 2020953b93 | |||
| 3826bb3d9e | |||
| 7ffc06f3c7 | |||
|
|
eea1a75d4a | ||
| b9bff38b64 | |||
| f04862933f | |||
| f5b8375fd3 | |||
| db1cdf4280 | |||
| fa1e86ff07 | |||
|
|
9a95454723 | ||
| dd1875ea5c | |||
|
|
df4fabb32a | ||
| 99114f14f6 | |||
|
|
fc3a69bbb0 | ||
| 9594be2606 | |||
|
|
96fd239608 | ||
| 61de7e9e22 | |||
|
|
24c4cdc39f | ||
| fadbf77866 | |||
| 03819a3d90 | |||
| d6d0777113 | |||
| 1aa83e0ef1 | |||
|
|
b09c644e01 | ||
| e403870874 | |||
| 1586ce2d6c | |||
| 576353cfe8 | |||
|
|
cefc415c98 | ||
| bc0ef7893e | |||
|
|
0e802d8194 | ||
| d7718d4dcb | |||
|
|
4c2e02e912 | ||
| b8774e0b0b | |||
| 6e75642090 | |||
| aaa0d1003d | |||
| 5960918137 | |||
| 3dc0532df0 | |||
| 96863adf53 | |||
|
|
08425a623e | ||
| b787759f44 | |||
|
|
25ef7c05e6 | ||
| c36bb80d6a | |||
|
|
c069f3e1b3 | ||
| 215d59c8bf | |||
| 008a33a9b1 | |||
| 3e787234c7 | |||
| 1173510105 | |||
| a391f3018c | |||
| b6e1e20b7c | |||
| 572f2fb811 | |||
| 2e2d422910 | |||
| f0556e4411 | |||
| 4a97105e4b | |||
| 797f73c39a | |||
| b8f796fd3f | |||
| 78673ea11a | |||
| c6a14c0768 | |||
|
|
70a966d8dc | ||
| c42511dd44 | |||
|
|
db62f9e998 | ||
| 0610d2f9f0 | |||
| c1dd0ee190 | |||
| a45c407568 | |||
|
|
813f57861c | ||
| 3faee98ec8 | |||
| ca02132c8d | |||
|
|
cb4ef25b73 | ||
| c8b7367815 | |||
| a268caaa30 | |||
| 6b25abff70 | |||
| 21c807f358 | |||
| 56fdae4275 | |||
| e6a06c9f43 | |||
| f979a63d3d | |||
|
|
327bc54e22 | ||
| a51b15da3f | |||
| 7271b422f9 | |||
| 1866ba66c8 | |||
|
|
6175a04a90 | ||
| 7120f3e93b | |||
| acc13183e2 | |||
| f75fc19c5b | |||
|
|
2650c8b8cf | ||
| 1de3cbf65a | |||
|
|
4a9d0c9e44 | ||
| 88ecd05b95 | |||
| df812eaad5 | |||
|
|
d62da494c8 | ||
| e631fc15d8 | |||
|
|
ecbf1ce0c8 | ||
| e5c0087c9a | |||
| 4348ed1bb2 | |||
|
|
5c11fde0a9 | ||
| 4ca1efeeb8 | |||
| aa7ce2ea27 | |||
|
|
174f0cdcb6 | ||
| 860517a321 | |||
|
|
66daae6d9e | ||
| 83001a0d82 | |||
| 1b7921a7f2 | |||
| 8badb6adc1 | |||
| 37682e7b8a | |||
| 56e74a0e7d | |||
| ec4574ed5c | |||
| 21d20e0fc7 | |||
| 7ce3a83c58 | |||
| 6dff1879c4 | |||
| c09644b29d | |||
| d8cf44134c | |||
| ca856384f3 | |||
| 4e2c9df6a4 | |||
| 8b822e0fa8 | |||
| 67d398caf7 | |||
|
|
c2c27f8279 | ||
| 50b3422528 | |||
| 4639eee0b9 | |||
| b4b27aea3d | |||
| e483b282db | |||
| 36391db607 | |||
| 5362334ff3 | |||
| fdf11d8147 | |||
|
|
204f653b72 | ||
| 48ae950d57 | |||
| 925c893f3f | |||
|
|
b54423a151 | ||
| ce374163ca | |||
| 3644f344da | |||
| d1266a1ce1 | |||
| f7d0b0768a | |||
| 630616ec72 | |||
|
|
7f7bef7581 | ||
| d2f2b206bb | |||
| 6fa1c06053 | |||
| 5d4ca816cd | |||
| 443b6c1d7b | |||
| 505a5ec833 | |||
|
|
3a7289bf5e | ||
| 2718bc6247 | |||
|
|
515d2651bf | ||
| ef25f56380 | |||
|
|
5b280ccc1e | ||
| cbbd23aa33 | |||
|
|
860d0ad014 | ||
| fa344a5799 | |||
|
|
3919de5bd5 | ||
| 1a0a98a453 | |||
| d79f7e9ccd | |||
| 50e41ff261 | |||
| 430b282039 | |||
|
|
17133771bb | ||
| e5a7d47b21 | |||
|
|
71ec61e27b | ||
| b3575eb068 | |||
| 216511b951 | |||
| 6dabbf874f | |||
|
|
d5aad06c88 | ||
| 5d6672069e | |||
| 140ad83380 | |||
| ea805d1362 | |||
| 9e16f2faf9 | |||
| 2a36d9364f | |||
| 27426ce7a5 | |||
|
|
69adadd6d7 | ||
| 6f96498de6 | |||
| 836b6e64f6 | |||
|
|
fab7dd7eec | ||
| 9263f8ef5c | |||
|
|
658728efef | ||
| 6b8432f5b2 | |||
| bc709c4184 | |||
| b49462abeb | |||
| d9d4e3c9bf | |||
| fe04dd80e5 | |||
|
|
718950cf0d | ||
| 17a0068757 | |||
| abc6caa2d0 | |||
|
|
99fb82561b | ||
| 61ba08d0b8 | |||
| 40b5688158 | |||
|
|
0a4e253cbd | ||
| 6428e38ab9 | |||
| fc4f4f81ad | |||
| f6629852eb | |||
| 3adf6cfd58 | |||
| b15816ca9f | |||
| 6b1d5827d6 | |||
| f0391f59c9 | |||
| 006a0894b8 | |||
| 9c5a471234 | |||
| 1c7f4912ce | |||
| df1be10057 | |||
| 954c576131 | |||
| 867720a897 | |||
| 2b40602bdc | |||
| 11173b9c0a | |||
|
|
52d46e77db | ||
| e7838b0f2f | |||
|
|
2ae3810cf6 | ||
| 178fe4d2da | |||
| 2d79ef8fe5 | |||
| d56c5493cd | |||
| cf6e5a40fc | |||
| 64abd67b9b | |||
|
|
c19e856800 | ||
| 02a26086c4 | |||
|
|
35f880bc2f | ||
| c0ddeceeea | |||
| 67fd5e8581 | |||
| bf699ec1fb | |||
| 3094632134 | |||
| 6985ff0fce | |||
| 33f7be42c5 | |||
|
|
36fac70361 | ||
| ca5e8d2fbb | |||
| 828067f486 | |||
|
|
e5f4c0b952 | ||
| edb1775967 | |||
|
|
d0d6908a74 | ||
| c037b87675 | |||
| 52bc322b2b | |||
| 8479caf53a | |||
| 82e2c898d2 |
211
.gitlab-ci.yml
@@ -22,6 +22,13 @@ workflow:
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v --random-order tests/"
|
||||
exclude_packages: ""
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
@@ -31,8 +38,29 @@ stages:
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-repos: &install-repos
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
- 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
|
||||
@@ -75,18 +103,21 @@ pylint-check:
|
||||
- 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 $CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $SOURCE_BRANCH_COMMIT_SHA $TARGET_BRANCH_COMMIT_SHA | grep '\.py$' || true);
|
||||
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);
|
||||
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 $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"
|
||||
|
||||
@@ -103,16 +134,13 @@ tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
|
||||
- coverage 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+)?\%)$/'
|
||||
@@ -122,126 +150,34 @@ tests:
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
tests-3.10-pyside6:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
- "pyqt6"
|
||||
|
||||
tests-3.12-pyside6:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
tests-3.10-pyqt5:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyqt5]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
tests-3.11-pyqt5:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyqt5]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
tests-3.12-pyqt5:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyqt5]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
tests-3.10-pyqt6:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
tests-3.11-pyqt6:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
tests-3.12-pyqt6:
|
||||
extends: "tests"
|
||||
stage: AdditionalTests
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
|
||||
script:
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
- *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
|
||||
@@ -251,8 +187,8 @@ end-2-end-conda:
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --prepend channels conda-forge
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
@@ -261,16 +197,11 @@ end-2-end-conda:
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
|
||||
- pip install -e ./bec_lib[dev]
|
||||
- pip install -e ./bec_ipython_client[dev]
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- cd ./tests/end-2-end
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
@@ -307,7 +238,7 @@ semver:
|
||||
- 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
|
||||
@@ -323,7 +254,7 @@ pages:
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG != null'
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=PyQt5, pyqtgraph
|
||||
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
|
||||
256
CHANGELOG.md
@@ -1,161 +1,161 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
|
||||
## v0.57.4 (2024-06-06)
|
||||
## v0.99.8 (2024-08-28)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(docks): set_title do update dock internal _name now ([`15cbc21`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/15cbc21e5bb3cf85f5822d44a2b3665b5aa2f346))
|
||||
|
||||
* fix(docks): docks widget_list adn dockarea panels return values fixed ([`ffae5ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ffae5ee54e6b43da660131092452adff195ba4fb))
|
||||
|
||||
|
||||
## v0.57.3 (2024-06-06)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs(bar): docs updated ([`4be0d14`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4be0d14b7445c2322c2aef86257db168a841265c))
|
||||
|
||||
* docs: fixed syntax of add_widget ([`a951ebf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a951ebf1be6c086d094aa8abef5e0dfd1b3b8558))
|
||||
|
||||
* docs: added auto update; closes #206 ([`32da803`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/32da803df9f7259842c43e85ba9a0ce29a266d06))
|
||||
|
||||
* docs: cleanup ([`07d60cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/07d60cf7355d2edadb3c5ef8b86607d74b360455))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(ring): automatic updates are disabled uf user specify updates manually with .set_update; 'scan_progres' do not reset number of rings ([`e883dba`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e883dbad814dbcc0a19c341041c6d836e58a5918))
|
||||
|
||||
* fix(ring): enable_auto_updates(True) do not reset properties of already setup bars ([`a2abad3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a2abad344f4c0039516eb60a825afb6822c5b19a))
|
||||
|
||||
* fix(ring): set_min_max accepts floats ([`d44b1cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d44b1cf8b107cf02deedd9154b77d01c7f9ed05d))
|
||||
|
||||
* fix(ring): set_update changed to Literals, no need to specify endpoint manually ([`c5b6499`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c5b6499e41eb1495bf260436ca3e1b036182c360))
|
||||
|
||||
|
||||
## v0.57.2 (2024-06-06)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(test/e2e): autoupdate e2e rewritten ([`e1af5ca`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e1af5ca60f0616835f9f41d84412f29dc298c644))
|
||||
|
||||
* fix(test/e2e): spiral_progress_bar e2e tests rewritten to use config_dict ([`7fb31fc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7fb31fc4d762ff4ca839971b3092a084186f81b8))
|
||||
|
||||
* fix(test/e2e): dockarea and dock e2e tests changed to check asserts against config_dict ([`5c6ba65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5c6ba65469863ea1e6fc5abdc742650e20eba9b9))
|
||||
|
||||
* fix: rpc_server_dock fixture now spawns the server process ([`cd9fc46`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cd9fc46ff8a947242c8c28adcd73d7de60b11c44))
|
||||
|
||||
* fix: accept scalars or numpy arrays of 1 element ([`2a88e17`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2a88e17b23436c55d25b7d3449e4af3a7689661c))
|
||||
* fix(website): fixed designer integration for website widget ([`5f37e86`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f37e862c95ac7173b6918ad39bcaef938dad698))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: move _get_output and _start_plot_process at the module level ([`69f4371`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/69f4371007c66aee6b7521a6803054025adf8c92))
|
||||
* refactor(website): changed inheritance of website widget to simple qwidget; closes #325 ([`9925bbd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9925bbdb48b55eacbbce9fd6a1555a21b84221f9))
|
||||
|
||||
## v0.99.7 (2024-08-28)
|
||||
|
||||
## v0.57.1 (2024-06-06)
|
||||
### Fix
|
||||
|
||||
* fix(toolbar): material icons can accept color as kwarg ([`ffc871e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ffc871ebbd3b68abc3e151bb8f5849e6c50e775e))
|
||||
|
||||
## v0.99.6 (2024-08-28)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: docs refactored from add_widget_bec to add_widget ([`c3f4845`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c3f4845b4f95005ff737fed5542600b0b9a9cc2b))
|
||||
* docs: various bugs fixed ([`c31e9a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c31e9a3aff3ee8e984674dee0965ee7f1b6e2b8f))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: tests references to add_widget_bec refactored ([`f51b25f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f51b25f0af4ab8b0a75ee48a40bfbb079c16e9d1))
|
||||
* fix(toolbar): use of native qt separators ([`09c6c93`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/09c6c93c397ce4a21c293f6c79106c74b2db65ca))
|
||||
|
||||
* fix(dock): add_widget and add_widget_bec consolidated ([`8ae323f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8ae323f5c3c0d9d0f202d31d5e8374a272a26be2))
|
||||
|
||||
|
||||
## v0.57.0 (2024-06-05)
|
||||
## v0.99.5 (2024-08-28)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: extend user documentation for BEC Widgets ([`4160f3d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4160f3d6d7ec1122785b5e3fdfc4afe67a95e9a1))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(widgets/console): BECJupyterConsole added ([`8c03034`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8c03034acf6b3ed1e346ebf1b785d41068513cc5))
|
||||
|
||||
|
||||
## v0.56.3 (2024-06-05)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: increased verbosity for e2e tests ([`4af1abe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4af1abe4e15b62d2f7e70bf987a1a7d8694ef4d5))
|
||||
* docs(index): index page is centered ([`02239de`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02239de0a36fcd6cbf97990b0dec1ddf7ecf6ba6))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: fixed support for auto updates ([`131f49d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/131f49da8ea65af4d44b50e81c1acfc29cd92093))
|
||||
* fix(dock_area): dark button added ([`e6f204b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6f204b6aa295747a68769f43af2e549149b401a))
|
||||
|
||||
|
||||
## v0.56.2 (2024-06-05)
|
||||
## v0.99.4 (2024-08-28)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs: restructured docs layout ([`3c9181d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3c9181d93d68faa4efb3b91c486ca9ca935975a0))
|
||||
* docs(buttons): added missing buttons docs ([`4e5520a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e5520aee2115d2fc0cebb3865433478a5ec8253))
|
||||
|
||||
* docs(developer): tutorial for BECWidget base class ([`ac2cb51`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac2cb5197deef4d51e26ee5beb070eba3ffc210d))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(bar): ring saves current value in config ([`9648e3e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9648e3ea96a4109be6be694d855151ed6d3ad661))
|
||||
|
||||
* fix(dock): dock saves configs of all children widgets ([`4be756a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4be756a8676421c3a3451458995232407295df84))
|
||||
|
||||
* fix(dock_area): save/restore state is saved in config ([`46face0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/46face0ee59122f04cb383da685a6658beeeb389))
|
||||
|
||||
* fix(figure): added correct types of configs to subplot widgets ([`6f3b1ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6f3b1ea985c18929b9bab54239eeb600f03b274a))
|
||||
|
||||
|
||||
## v0.56.1 (2024-06-04)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(spiral_progress_bar/rings): config min/max values added check for floats ([`9d615c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9d615c915c8f7cc2ea8f1dc17993b98fe462c682))
|
||||
|
||||
* fix(spiral_progress_bar): Endpoint is always stored as a string in the RingConnection Config ([`d253991`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2539918b296559e1d684344e179775a2423daa9))
|
||||
|
||||
|
||||
## v0.56.0 (2024-05-29)
|
||||
|
||||
### Build
|
||||
|
||||
* build: added pyside6 as dependency ([`db301b1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/db301b1be27bba76c8bb21fbff93cb4902b592a5))
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: added tests for pyside6, pyqt6 and pyqt5, default test and e2e is python 3.11 and pyqt6 ([`855be35`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/855be3551a1372bcbebba6f8930903f99202bbca))
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs(examples): example apps section deleted ([`ad208a5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ad208a5ef8495c45a8b83a4850ba9a1041b42717))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(utils/ui_loader): universal ui loader for pyside/pyqt ([`0fea8d6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0fea8d606574fa99dda3b117da5d5209c251f694))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(examples): outdated examples removed (mca_plot.py, stream_plot.py, motor_example.py) ([`ddc9510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ddc9510c2ba8dadf291809eeb5b135a105259492))
|
||||
|
||||
* fix: compatibility adjustment to .ui loading and tests for PySide6 ([`07b99d9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/07b99d91a57a645cddd76294f48d78773e4c9ea5))
|
||||
|
||||
|
||||
## v0.55.0 (2024-05-24)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(widgets/progressbar): SpiralProgressBar added with rpc interface ([`76bd0d3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/76bd0d339ac9ae9e8a3baa0d0d4e951ec1d09670))
|
||||
|
||||
|
||||
## v0.54.0 (2024-05-24)
|
||||
|
||||
### Build
|
||||
|
||||
* build: added pyqt6 as sphinx build dependency ([`a47a8ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a47a8ec413934cf7fce8d5b7a5913371d4b3b4a5))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(figure): changes to support direct plot functionality ([`fc4d0f3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc4d0f3bb2a7c2fca9c326d86eb68b305bcd548b))
|
||||
* fix(theme): apply theme to all pyqtgraph widgets on manual updates ([`c550186`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c5501860e8e07a53f4bce144d44ed39eda6290ef))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(reconstruction): repository structure is changed to separate assets needed for each widget ([`3455c60`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3455c602361d3b5cc3ff9190f9d2870474becf8a))
|
||||
* refactor(buttons): changed grid and thumbnail fig in gallery ([`4591ba8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4591ba8f73e22aba7258cad93c073f1387cb74a0))
|
||||
|
||||
* refactor(icons): removed toolbar icons from assets ([`f335763`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f335763280adb1d83ba31f073ce206e4cb5d15ef))
|
||||
|
||||
* refactor(icons): moved widget icons to class attribute ICON_NAME ([`e890091`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e890091d862e42317c7a54fc414ba37c85f268b0))
|
||||
|
||||
## v0.99.3 (2024-08-27)
|
||||
|
||||
### Build
|
||||
|
||||
* build: updated min version of bec qthemes ([`d482434`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d48243483ef8228cc5eb85e40a6b8f5da3b45520))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(cmaps): unified all defaults to magma cmap ([`1ca9499`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1ca9499edd334c19fe1e7aac71d3940a80a1ec95))
|
||||
|
||||
* fix(color maps): color maps should take the background color into account; fixed min colors to 10 ([`060935f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/060935ffc5472a958c337bf60834c5291f104ece))
|
||||
|
||||
## v0.99.2 (2024-08-27)
|
||||
|
||||
### Ci
|
||||
|
||||
* ci: additional tests are not allowed to fail ([`bb385f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bb385f07ca18904461a541b5cadde05398c84438))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(widgets): fixed default theme for widgets
|
||||
|
||||
If not theme is set, the init of the BECWidget base class sets the default theme to "dark" ([`cf28730`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf28730515e3c2d5914e0205768734c578711e5c))
|
||||
|
||||
## v0.99.1 (2024-08-27)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(crosshair): emit all crosshair events, not just line coordinates ([`2265458`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2265458dcc57970db18c62619f5877d542d72e81))
|
||||
|
||||
## v0.99.0 (2024-08-25)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs(darkmodebutton): added dark mode button docs ([`406c263`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/406c263746f0e809c1a4d98356c48f40428c23d7))
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(darkmodebutton): added button to toggle between dark and light mode ([`cc8c166`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cc8c166b5c1d37e0f64c83801b2347a54a6550b6))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(toggle): emit state change ([`c4f3308`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c4f3308dc0c3e4b2064760ccd7372d71b3e49f96))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(darkmodebutton): renamed set_dark_mode_enabled to toggle_dark_mode ([`c70724a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c70724a456900bcb06b040407a2c5d497e49ce77))
|
||||
|
||||
### Test
|
||||
|
||||
* test(dark_mode_button): added tests for dark mode button ([`df35aab`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df35aabff30c5d00b1c441132bd370446653741e))
|
||||
|
||||
## v0.98.0 (2024-08-25)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(themes): added set_theme method ([`2b4449a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b4449afebdda0a97f95712a1353cf40ec55c283))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(toolbar): removed hardcoded color values ([`afdf4e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/afdf4e8782a22566932180224fa1c924d24c810f))
|
||||
|
||||
* fix: transitioning to material icons ([`2a82032`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2a82032644a84e38df04e2035a6aa63f4a046360))
|
||||
|
||||
* fix(dock_area): transitioned to MaterialIconAction ([`88a2f66`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/88a2f667588e9aeb34ae556fa327898824052bc3))
|
||||
|
||||
* fix: fix color palette if qtheme was not called ([`3f3b207`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3f3b207295ebd406ebaeecee465c774965161b8b))
|
||||
|
||||
* fix(figure): removed theme from figure init ([`e42b84c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e42b84c63650297d67feffccc02a2c2ba111ca79))
|
||||
|
||||
* fix: use globally set theme instead of the internal bec widgets theme ([`77c5aa7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/77c5aa741cf1f5b969a42aa878aa2965176dbf41))
|
||||
|
||||
* fix(waveform): fixed icon appearance ([`36ad464`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/36ad4641594b67c9b789515c28f7db78a12757ee))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(waveform): use set theme for demo ([`44cfda1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/44cfda1c07306669c9a4e09706d95e6b91dee370))
|
||||
|
||||
## v0.97.0 (2024-08-23)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(designer): added designer icon factory ([`82a55dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/82a55ddf3eafb589cb63408db1c0e7e5c9d629da))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(toolbar icon): fixed material icon toolbar for theme changes ([`3ecbd60`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3ecbd60627994417c9175364e5909710dbcdceb2))
|
||||
|
||||
## v0.96.3 (2024-08-23)
|
||||
|
||||
### Documentation
|
||||
|
||||
* docs(dispatcher): docs added ([`dd7c71b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dd7c71bb1e0b7ef5398b1e1a05fc1147c772420a))
|
||||
|
||||
### Fix
|
||||
|
||||
* fix: minor fixes for type annotations ([`8c2e7c8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8c2e7c82592ace50e4e1f47e392a0ddc988f57ae))
|
||||
|
||||
## v0.96.2 (2024-08-22)
|
||||
|
||||
### Fix
|
||||
|
||||
* fix(waveform): validation of custom curves removed ([`af28574`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af28574bd58457a05f1269f121db01ad627b5769))
|
||||
|
||||
@@ -17,7 +17,7 @@ cd bec_widgets
|
||||
pip install -e .[dev,pyqt6]
|
||||
```
|
||||
|
||||
BEC Widgets currently supports both PyQt5 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
Python Qt distributions manually.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
@@ -28,7 +28,7 @@ pip install bec_widgets[pyqt6]
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt5]
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
|
||||
BIN
bec_widgets/assets/app_icons/BEC-Dark.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
3
bec_widgets/assets/status_icons/error.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
|
||||
<path d="M479.85-265.87q19.8 0 32.69-12.46 12.9-12.46 12.9-32.26 0-19.8-12.75-32.98-12.74-13.17-32.54-13.17-19.8 0-32.69 13.16-12.9 13.15-12.9 32.95 0 19.8 12.75 32.28 12.74 12.48 32.54 12.48Zm-36.46-166.56h79.22v-262.61h-79.22v262.61Zm36.95 366.56q-86.2 0-161.5-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.73-32.39-75.3-32.39-161.59t32.39-161.67q32.4-75.37 88.75-131.34t131.69-88.62q75.34-32.65 161.67-32.65 86.34 0 161.78 32.61 75.45 32.6 131.37 88.5 55.93 55.89 88.55 131.45 32.63 75.56 32.63 161.87 0 86.29-32.65 161.58t-88.62 131.48q-55.97 56.18-131.42 88.76-75.46 32.58-161.67 32.58Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 718 B |
3
bec_widgets/assets/status_icons/not_connected.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
|
||||
<path d="m759.04-283.09-63.13-62q49.31-9.43 84.44-46.02 35.13-36.59 35.13-86.14 0-55.49-39.42-95.08-39.43-39.58-94.93-39.58h-153.3v-79.79h152.74q89.28 0 151.7 62.71Q894.7-566.28 894.7-477q0 63.7-38.26 115.96-38.27 52.26-97.4 77.95ZM596.83-443.61l-65.66-66.78h110.05v66.78h-44.39ZM804.96-56 58.48-802.48 106-850l746.48 746.48L804.96-56ZM443.22-265.87H279.43q-89.28 0-151.7-62.42Q65.3-390.72 65.3-480q0-72.57 43.09-129.54 43.09-56.98 112.09-76.07l70.13 70.7h-11.18q-55.73 0-95.32 39.3-39.59 39.31-39.59 95.61t39.66 95.61q39.66 39.3 95.5 39.3h163.54v79.22ZM319.35-446.61v-66.78h77.3l66.78 66.78H319.35Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
3
bec_widgets/assets/status_icons/refresh.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFF55">
|
||||
<path d="M478.3-145.87q-138.65 0-236.39-97.74-97.74-97.74-97.74-236.25t97.74-236.68q97.74-98.16 236.39-98.16 88.4 0 155.45 35.76 67.04 35.76 115.86 98.9V-814.7h66.78v274.92H540.91V-606h165.74q-38.56-57.74-95.3-93.33-56.74-35.58-133.05-35.58-106.88 0-180.89 73.98-74.02 73.99-74.02 180.83 0 106.84 74.02 180.93 74.02 74.08 180.91 74.08 80.16 0 147.74-46.08 67.59-46.09 95.16-121.83H803q-29.56 110.65-119.67 178.89-90.1 68.24-205.03 68.24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 559 B |
3
bec_widgets/assets/status_icons/running.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#75FB4C">
|
||||
<path d="m419.87-289.52 289.22-289.22-57.31-56.87L419.87-403.7 304.96-518.61l-56.31 56.87 171.22 172.22Zm60.21 223.65q-85.47 0-161.01-32.39-75.53-32.4-131.97-88.84-56.44-56.44-88.84-131.89-32.39-75.46-32.39-160.93 0-86.47 32.39-162.01 32.4-75.53 88.75-131.5t131.85-88.62q75.5-32.65 161.01-32.65 86.52 0 162.12 32.61 75.61 32.6 131.53 88.5 55.93 55.89 88.55 131.45Q894.7-566.58 894.7-480q0 85.55-32.65 161.07-32.65 75.53-88.62 131.9-55.97 56.37-131.42 88.77-75.46 32.39-161.93 32.39Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
3
bec_widgets/assets/status_icons/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#F19E39">
|
||||
<path d="M27.56-112.65 480-894.7l452.44 782.05H27.56Zm456.62-125.48q13.15 0 22.61-9.64 9.47-9.65 9.47-22.8t-9.64-22.33q-9.65-9.19-22.8-9.19t-22.61 9.36q-9.47 9.36-9.47 22.51 0 13.15 9.64 22.62 9.65 9.47 22.8 9.47ZM454-348h60v-219.48h-60V-348Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@@ -1 +1 @@
|
||||
from .client import BECDockArea, BECFigure
|
||||
from .client import *
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from queue import Queue
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -15,6 +17,7 @@ class ScanInfo(BaseModel):
|
||||
scan_report_devices: list
|
||||
monitored_devices: list
|
||||
status: str
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class AutoUpdates:
|
||||
@@ -24,6 +27,17 @@ class AutoUpdates:
|
||||
|
||||
def __init__(self, gui: BECDockArea):
|
||||
self.gui = gui
|
||||
self.msg_queue = Queue()
|
||||
self.auto_update_thread = None
|
||||
self._shutdown_sentinel = object()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the auto update thread.
|
||||
"""
|
||||
self.auto_update_thread = threading.Thread(target=self.process_queue)
|
||||
self.auto_update_thread.start()
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
@@ -78,6 +92,16 @@ class AutoUpdates:
|
||||
info = self.get_scan_info(msg)
|
||||
self.handler(info)
|
||||
|
||||
def process_queue(self):
|
||||
"""
|
||||
Process the message queue.
|
||||
"""
|
||||
while True:
|
||||
msg = self.msg_queue.get()
|
||||
if msg is self._shutdown_sentinel:
|
||||
break
|
||||
self.run(msg)
|
||||
|
||||
@staticmethod
|
||||
def get_selected_device(monitored_devices, selected_device):
|
||||
"""
|
||||
@@ -117,7 +141,7 @@ class AutoUpdates:
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y)
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def simple_grid_scan(self, info: ScanInfo) -> None:
|
||||
@@ -131,7 +155,9 @@ class AutoUpdates:
|
||||
dev_y = info.scan_report_devices[1]
|
||||
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number}")
|
||||
plt = fig.plot(
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
)
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def best_effort(self, info: ScanInfo) -> None:
|
||||
@@ -146,5 +172,13 @@ class AutoUpdates:
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number}")
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the auto update thread.
|
||||
"""
|
||||
self.msg_queue.put(self._shutdown_sentinel)
|
||||
if self.auto_update_thread:
|
||||
self.auto_update_thread.join()
|
||||
|
||||
@@ -2,19 +2,20 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as imd
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
|
||||
from qtpy.QtCore import QCoreApplication
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
@@ -22,13 +23,13 @@ from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea, BECFigure
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
@@ -61,46 +62,67 @@ def rpc_call(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_output(process) -> None:
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
if process.stdout in readylist:
|
||||
output = process.stdout.read(1024)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
buf = stream_buffer[stream]
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
if output:
|
||||
print(output, end="")
|
||||
if process.stderr in readylist:
|
||||
error_output = process.stderr.read(1024)
|
||||
if error_output:
|
||||
print(error_output, end="", file=sys.stderr)
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
buf.append(remaining)
|
||||
except Exception as e:
|
||||
print(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id, gui_class, config) -> None:
|
||||
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
Logger must be a logger object with "debug" and "error" functions,
|
||||
or it can be left to "None" as default. None means output from the
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
monitor_module = importlib.import_module("bec_widgets.cli.server")
|
||||
monitor_path = monitor_module.__file__
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
command.extend(["--config", config])
|
||||
|
||||
command = [
|
||||
sys.executable,
|
||||
"-u",
|
||||
monitor_path,
|
||||
"--id",
|
||||
gui_id,
|
||||
"--config",
|
||||
config,
|
||||
"--gui_class",
|
||||
gui_class.__name__,
|
||||
]
|
||||
process = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
|
||||
process_output_processing_thread.start()
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
|
||||
if logger is None:
|
||||
stdout_redirect = subprocess.DEVNULL
|
||||
stderr_redirect = subprocess.DEVNULL
|
||||
else:
|
||||
stdout_redirect = subprocess.PIPE
|
||||
stderr_redirect = subprocess.PIPE
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
stdout=stdout_redirect,
|
||||
stderr=stderr_redirect,
|
||||
env=env_dict,
|
||||
)
|
||||
if logger is None:
|
||||
process_output_processing_thread = None
|
||||
else:
|
||||
process_output_processing_thread = threading.Thread(
|
||||
target=_get_output, args=(process, logger)
|
||||
)
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
@@ -112,13 +134,16 @@ class BECGuiClientMixin:
|
||||
self.auto_updates = self._get_update_script()
|
||||
self._target_endpoint = MessageEndpoints.scan_status()
|
||||
self._selected_device = None
|
||||
self.stderr_output = []
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
for ep in eps:
|
||||
if ep.name == "plugin_widgets_update":
|
||||
try:
|
||||
spec = importlib.util.find_spec(ep.module)
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self)
|
||||
except Exception as e:
|
||||
print(f"Error loading auto update script from plugin: {str(e)}")
|
||||
@@ -155,7 +180,7 @@ class BECGuiClientMixin:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
self.auto_updates.run(msg)
|
||||
self.auto_updates.msg_queue.put(msg)
|
||||
|
||||
def show(self) -> None:
|
||||
"""
|
||||
@@ -164,7 +189,7 @@ class BECGuiClientMixin:
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
self._start_update_script()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.redis
|
||||
self._gui_id, self.__class__, self._client._service_config.config
|
||||
)
|
||||
while not self.gui_is_alive():
|
||||
print("Waiting for GUI to start...")
|
||||
@@ -172,28 +197,20 @@ class BECGuiClientMixin:
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the figure.
|
||||
Close the gui window.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
if self.gui_is_alive():
|
||||
self._run_rpc("close", (), wait_for_rpc_response=True)
|
||||
else:
|
||||
self._run_rpc("close", (), wait_for_rpc_response=False)
|
||||
self._process.terminate()
|
||||
self._process_output_processing_thread.join()
|
||||
self._process = None
|
||||
self._client.shutdown()
|
||||
|
||||
def print_log(self) -> None:
|
||||
"""
|
||||
Print the log of the plot process.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
print("".join(self.stderr_output))
|
||||
# Flush list
|
||||
self.stderr_output.clear()
|
||||
self._client.shutdown()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
if self.auto_updates is not None:
|
||||
self.auto_updates.shutdown()
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
@@ -207,10 +224,12 @@ class RPCResponseTimeoutError(Exception):
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECDispatcher().client
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
@@ -231,7 +250,7 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -253,16 +272,39 @@ class RPCBase:
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if not wait_for_rpc_response:
|
||||
return None
|
||||
response = self._wait_for_response(request_id)
|
||||
# get class name
|
||||
if not response.content["accepted"]:
|
||||
raise ValueError(response.content["message"]["error"])
|
||||
msg_result = response.content["message"].get("result")
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(10)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
@@ -285,33 +327,13 @@ class RPCBase:
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def _wait_for_response(self, request_id: str, timeout: int = 5):
|
||||
"""
|
||||
Wait for the response from the server.
|
||||
|
||||
Args:
|
||||
request_id(str): The request ID.
|
||||
timeout(int): The timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The response from the server.
|
||||
"""
|
||||
start_time = time.time()
|
||||
response = None
|
||||
|
||||
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
|
||||
response = self._client.connector.get(
|
||||
MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
|
||||
if response is None and (time.time() - start_time) >= timeout:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
|
||||
return response
|
||||
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
return heart is not None
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
|
||||
import black
|
||||
import isort
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
@@ -14,30 +20,55 @@ else:
|
||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||
)
|
||||
|
||||
def get_overloads(obj):
|
||||
# Dummy function for Python versions before 3.11
|
||||
def get_overloads(_obj):
|
||||
"""
|
||||
Dummy function for Python versions before 3.11.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECGuiClientMixin
|
||||
from typing import Literal, Optional, overload"""
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(self, published_classes: list):
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
for cls in published_classes:
|
||||
rpc_top_level_classes = class_container.rpc_top_level_classes
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
connector_classes = class_container.connector_classes
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
def write_client_enum(self, published_classes: list[type]):
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
self.content += """
|
||||
class Widgets(str, enum.Enum):
|
||||
\"\"\"
|
||||
Enum for the available widgets.
|
||||
\"\"\"
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
Generate the content for the class.
|
||||
@@ -47,11 +78,6 @@ from typing import Literal, Optional, overload"""
|
||||
"""
|
||||
|
||||
class_name = cls.__name__
|
||||
module = cls.__module__
|
||||
|
||||
# Generate the header
|
||||
# self.header += f"""
|
||||
# from {module} import {class_name}"""
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECDockArea":
|
||||
@@ -60,6 +86,9 @@ class {class_name}(RPCBase, BECGuiClientMixin):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
for method in cls.USER_ACCESS:
|
||||
obj = getattr(cls, method)
|
||||
if isinstance(obj, property):
|
||||
@@ -101,39 +130,60 @@ class {class_name}(RPCBase):"""
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=True,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
|
||||
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.core:
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
|
||||
rpc_classes = get_rpc_classes("bec_widgets")
|
||||
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
# if the class directory already has a register, plugin and pyproject file, skip
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
|
||||
):
|
||||
continue
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import os
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets import BECDock, BECDockArea, BECFigure, SpiralProgressBar
|
||||
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
|
||||
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem
|
||||
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap
|
||||
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase
|
||||
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform
|
||||
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import BECCurve
|
||||
from bec_widgets.widgets.spiral_progress_bar.ring import Ring
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
clss = [
|
||||
BECPlotBase,
|
||||
BECWaveform,
|
||||
BECFigure,
|
||||
BECCurve,
|
||||
BECImageShow,
|
||||
BECConnector,
|
||||
BECImageItem,
|
||||
BECMotorMap,
|
||||
BECDock,
|
||||
BECDockArea,
|
||||
SpiralProgressBar,
|
||||
Ring,
|
||||
]
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(clss)
|
||||
generator.write(client_path)
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
main()
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
widget_classes = {"BECFigure": BECFigure, "SpiralProgressBar": SpiralProgressBar}
|
||||
def __init__(self):
|
||||
self._widget_classes = None
|
||||
|
||||
@staticmethod
|
||||
def create_widget(widget_type, **kwargs) -> BECConnector:
|
||||
@property
|
||||
def widget_classes(self):
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
Update the available widgets.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
clss = get_rpc_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
@@ -20,7 +42,12 @@ class RPCWidgetHandler:
|
||||
Returns:
|
||||
widget(BECConnector): The created widget.
|
||||
"""
|
||||
widget_class = RPCWidgetHandler.widget_classes.get(widget_type)
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
|
||||
from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str = None,
|
||||
gui_id: str,
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union["BECFigure", "BECDockArea"] = BECFigure,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
) -> None:
|
||||
self.status = messages.BECStatus.BUSY
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
self.client.start()
|
||||
@@ -37,10 +48,11 @@ class BECWidgetsCLIServer:
|
||||
)
|
||||
|
||||
# Setup QTimer for heartbeat
|
||||
self._shutdown_event = False
|
||||
self._heartbeat_timer = QTimer()
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200) # Emit heartbeat every 1 seconds
|
||||
self._heartbeat_timer.start(200)
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
@@ -101,23 +113,61 @@ class BECWidgetsCLIServer:
|
||||
return obj
|
||||
|
||||
def emit_heartbeat(self):
|
||||
if self._shutdown_event is False:
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=1, info={}),
|
||||
expire=10,
|
||||
)
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
|
||||
expire=10,
|
||||
)
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
self._shutdown_event = True
|
||||
self.status = messages.BECStatus.IDLE
|
||||
self._heartbeat_timer.stop()
|
||||
self.emit_heartbeat()
|
||||
self.gui.close()
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
class SimpleFileLikeFromLogOutputFunc:
|
||||
def __init__(self, log_func):
|
||||
self._log_func = log_func
|
||||
self._buffer = []
|
||||
|
||||
def write(self, buffer):
|
||||
self._buffer.append(buffer)
|
||||
|
||||
def flush(self):
|
||||
lines, _, remaining = "".join(self._buffer).rpartition("\n")
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
|
||||
if config:
|
||||
try:
|
||||
config = json.loads(config)
|
||||
service_config = ServiceConfig(config=config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
service_config = ServiceConfig(config_path=config)
|
||||
else:
|
||||
# if no config is provided, use the default config
|
||||
service_config = ServiceConfig()
|
||||
|
||||
bec_logger.configure(
|
||||
service_config.redis,
|
||||
QtRedisConnector,
|
||||
service_name="BECWidgetsCLIServer",
|
||||
service_config=service_config.service_config,
|
||||
)
|
||||
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
|
||||
return server
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
@@ -125,16 +175,6 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import bec_widgets
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("BEC Figure")
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
win = QMainWindow()
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
parser.add_argument(
|
||||
@@ -142,7 +182,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config to connect to redis.")
|
||||
parser.add_argument("--config", type=str, help="Config file or config string.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -157,12 +197,41 @@ if __name__ == "__main__": # pragma: no cover
|
||||
)
|
||||
gui_class = BECFigure
|
||||
|
||||
server = BECWidgetsCLIServer(gui_id=args.id, config=args.config, gui_class=gui_class)
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("BEC Figure")
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
win.show()
|
||||
win = QMainWindow()
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
|
||||
app.aboutToQuit.connect(server.shutdown)
|
||||
sys.exit(app.exec())
|
||||
server = _start_server(args.id, gui_class, args.config)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
win.show()
|
||||
|
||||
app.aboutToQuit.connect(server.shutdown)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["bec_widgets.cli.server", "--id", "test", "--gui_class", "BECDockArea"]
|
||||
main()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from .motor_movement import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorControlPanelRelative,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
|
||||
92
bec_widgets/examples/general_app/general_app.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECGeneralApp(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super(BECGeneralApp, self).__init__(parent)
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
|
||||
self.load_ui(ui_file_path)
|
||||
|
||||
self.resize(1280, 720)
|
||||
|
||||
self.ini_ui()
|
||||
|
||||
def ini_ui(self):
|
||||
self._setup_icons()
|
||||
self._hook_menubar_docs()
|
||||
self._hook_theme_bar()
|
||||
|
||||
def load_ui(self, ui_file):
|
||||
loader = UILoader(self)
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def _hook_menubar_docs(self):
|
||||
# BEC Docs
|
||||
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
|
||||
# BEC Widgets Docs
|
||||
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
|
||||
# Bug report
|
||||
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
|
||||
|
||||
def change_theme(self, theme):
|
||||
apply_theme(theme)
|
||||
|
||||
def _setup_icons(self):
|
||||
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
|
||||
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
|
||||
computer_icon = QIcon.fromTheme("computer")
|
||||
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
|
||||
|
||||
self.ui.action_BEC_docs.setIcon(help_icon)
|
||||
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
|
||||
self.ui.action_bug_report.setIcon(bug_icon)
|
||||
|
||||
self.ui.central_tab.setTabIcon(0, widget_icon)
|
||||
self.ui.central_tab.setTabIcon(1, computer_icon)
|
||||
|
||||
def _hook_theme_bar(self):
|
||||
self.ui.action_light.setCheckable(True)
|
||||
self.ui.action_dark.setCheckable(True)
|
||||
|
||||
# Create an action group to make sure only one can be checked at a time
|
||||
theme_group = QActionGroup(self)
|
||||
theme_group.addAction(self.ui.action_light)
|
||||
theme_group.addAction(self.ui.action_dark)
|
||||
theme_group.setExclusive(True)
|
||||
|
||||
# Connect the actions to the theme change method
|
||||
|
||||
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
|
||||
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
self.ui.action_dark.trigger()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-Dark.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
main_window = BECGeneralApp()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
262
bec_widgets/examples/general_app/general_app.ui
Normal file
@@ -0,0 +1,262 @@
|
||||
<?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>1718</width>
|
||||
<height>1139</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<property name="tabShape">
|
||||
<enum>QTabWidget::TabShape::Rounded</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="central_tab">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="dock_area_tab">
|
||||
<attribute name="title">
|
||||
<string>Dock Area</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECDockArea" name="dock_area"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="vscode_tab">
|
||||
<attribute name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::Computer"/>
|
||||
</attribute>
|
||||
<attribute name="title">
|
||||
<string>Visual Studio Code</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="VSCodeEditor" name="vscode"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>31</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="action_BEC_docs"/>
|
||||
<addaction name="action_BEC_widgets_docs"/>
|
||||
<addaction name="action_bug_report"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuTheme">
|
||||
<property name="title">
|
||||
<string>Theme</string>
|
||||
</property>
|
||||
<addaction name="action_light"/>
|
||||
<addaction name="action_dark"/>
|
||||
</widget>
|
||||
<addaction name="menuTheme"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QDockWidget" name="dock_scan_control">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Control</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_status_2">
|
||||
<property name="windowTitle">
|
||||
<string>BEC Service Status</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_3">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box_2"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_queue">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Queue</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_4">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<row/>
|
||||
<column/>
|
||||
<column/>
|
||||
<column/>
|
||||
<item row="0" column="0"/>
|
||||
<item row="0" column="1"/>
|
||||
<item row="0" column="2"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<action name="action_BEC_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_BEC_widgets_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Widgets Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_bug_report">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogError"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Bug Report</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_light">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Light</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_dark">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Dark</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWebEngineView</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>VSCodeEditor</class>
|
||||
<extends>WebsiteWidget</extends>
|
||||
<header>vs_code_editor</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECDockArea</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dock_area</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>QWebEngineView</class>
|
||||
<extends></extends>
|
||||
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
15
bec_widgets/examples/general_app/web_links.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import webbrowser
|
||||
|
||||
|
||||
class BECWebLinksMixin:
|
||||
@staticmethod
|
||||
def open_bec_docs():
|
||||
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_widgets_docs():
|
||||
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
@@ -2,33 +2,24 @@ import os
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import qdarktheme
|
||||
from qtconsole.inprocess import QtInProcessKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher, UILoader
|
||||
from bec_widgets.widgets import BECFigure
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
|
||||
# def __init__(self):
|
||||
# super().__init__()
|
||||
#
|
||||
# self.kernel_manager = QtInProcessKernelManager()
|
||||
# self.kernel_manager.start_kernel(show_banner=False)
|
||||
# self.kernel_client = self.kernel_manager.client()
|
||||
# self.kernel_client.start_channels()
|
||||
#
|
||||
# self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
|
||||
#
|
||||
# def shutdown_kernel(self):
|
||||
# self.kernel_client.stop_channels()
|
||||
# self.kernel_manager.shutdown_kernel()
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
|
||||
@@ -36,14 +27,8 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader().load_ui(os.path.join(current_path, "jupyter_console_window.ui"), self)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self.ui.splitter.setSizes([200, 100])
|
||||
self.safe_close = False
|
||||
|
||||
# console push
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
@@ -55,25 +40,49 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"w1": self.w1,
|
||||
"w2": self.w2,
|
||||
"w3": self.w3,
|
||||
"w4": self.w4,
|
||||
"w5": self.w5,
|
||||
"w6": self.w6,
|
||||
"w7": self.w7,
|
||||
"w8": self.w8,
|
||||
"w9": self.w9,
|
||||
"w10": self.w10,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"fig0": self.fig0,
|
||||
"fig1": self.fig1,
|
||||
"fig2": self.fig2,
|
||||
"bar": self.bar,
|
||||
"wave": self.wf,
|
||||
# "bar": self.bar,
|
||||
# "cm": self.colormap,
|
||||
"im": self.im,
|
||||
"mm": self.mm,
|
||||
}
|
||||
)
|
||||
|
||||
def _init_ui(self):
|
||||
# Plotting window
|
||||
self.glw_1_layout = QVBoxLayout(self.ui.glw) # Create a new QVBoxLayout
|
||||
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
|
||||
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
|
||||
self.dock = BECDockArea(gui_id="remote")
|
||||
self.dock_layout.addWidget(self.dock)
|
||||
# Horizontal splitter
|
||||
splitter = QSplitter(self)
|
||||
self.layout.addWidget(splitter)
|
||||
|
||||
tab_widget = QTabWidget(splitter)
|
||||
|
||||
first_tab = QWidget()
|
||||
first_tab_layout = QVBoxLayout(first_tab)
|
||||
self.dock = BECDockArea(gui_id="dock")
|
||||
first_tab_layout.addWidget(self.dock)
|
||||
tab_widget.addTab(first_tab, "Dock Area")
|
||||
|
||||
second_tab = QWidget()
|
||||
second_tab_layout = QVBoxLayout(second_tab)
|
||||
self.figure = BECFigure(parent=self, gui_id="figure")
|
||||
second_tab_layout.addWidget(self.figure)
|
||||
tab_widget.addTab(second_tab, "BEC Figure")
|
||||
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
@@ -81,51 +90,102 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
# init dock for testing
|
||||
self._init_dock()
|
||||
|
||||
self.console_layout = QVBoxLayout(self.ui.widget_console)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
self.console_layout.addWidget(self.console)
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_figure(self):
|
||||
self.figure.plot(x_name="samx", y_name="bpm4d")
|
||||
self.figure.motor_map("samx", "samy")
|
||||
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
|
||||
|
||||
self.figure.change_layout(2, 2)
|
||||
|
||||
self.w1 = self.figure[0, 0]
|
||||
self.w2 = self.figure[0, 1]
|
||||
self.w3 = self.figure[1, 0]
|
||||
|
||||
# curves for w1
|
||||
self.w1.add_curve_scan("samx", "samy", "bpm4i", pen_style="dash")
|
||||
self.w1.add_curve_scan("samx", "samy", "bpm3a", pen_style="dash")
|
||||
self.c1 = self.w1.get_config()
|
||||
self.w1 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="bpm4i",
|
||||
# title="Standard Plot with sync device, custom labels - w1",
|
||||
# x_label="Motor Position",
|
||||
# y_label="Intensity (A.U.)",
|
||||
row=0,
|
||||
col=0,
|
||||
)
|
||||
self.w1.set(
|
||||
title="Standard Plot with sync device, custom labels - w1",
|
||||
x_label="Motor Position",
|
||||
y_label="Intensity (A.U.)",
|
||||
)
|
||||
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
|
||||
self.w3 = self.figure.image(
|
||||
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
|
||||
)
|
||||
self.w4 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="samy",
|
||||
z_name="bpm4i",
|
||||
color_map_z="magma",
|
||||
new=True,
|
||||
title="2D scatter plot - w4",
|
||||
row=0,
|
||||
col=3,
|
||||
)
|
||||
self.w5 = self.figure.plot(
|
||||
y_name="bpm4i",
|
||||
new=True,
|
||||
title="Best Effort Plot - w5",
|
||||
dap="GaussianModel",
|
||||
row=1,
|
||||
col=0,
|
||||
)
|
||||
self.w6 = self.figure.plot(
|
||||
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
|
||||
)
|
||||
self.w7 = self.figure.plot(
|
||||
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
|
||||
)
|
||||
self.w8 = self.figure.plot(
|
||||
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
|
||||
)
|
||||
self.w9 = self.figure.plot(
|
||||
x_name="timestamp",
|
||||
y_name="monitor_async",
|
||||
new=True,
|
||||
title="Async Plot - timestamp - w9",
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
self.w10 = self.figure.plot(
|
||||
x_name="index",
|
||||
y_name="monitor_async",
|
||||
new=True,
|
||||
title="Async Plot - index - w10",
|
||||
row=2,
|
||||
col=2,
|
||||
)
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.fig0 = self.d0.add_widget("BECFigure")
|
||||
self.fig0.image("eiger", vrange=(0, 100))
|
||||
self.mm = self.d0.add_widget("BECMotorMapWidget")
|
||||
self.mm.change_motors("samx", "samy")
|
||||
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.fig1 = self.d1.add_widget("BECFigure")
|
||||
self.fig1.plot(x_name="samx", y_name="bpm4i")
|
||||
self.fig1.plot(x_name="samx", y_name="bpm3a")
|
||||
self.im = self.d1.add_widget("BECImageWidget")
|
||||
self.im.image("eiger")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
self.fig2.motor_map(x_name="samx", y_name="samy")
|
||||
self.fig2.plot(x_name="samx", y_name="bpm4i")
|
||||
self.bar = self.d2.add_widget("SpiralProgressBar", row=0, col=1)
|
||||
self.bar.set_diameter(200)
|
||||
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
|
||||
self.wf.plot(x_name="samx", y_name="bpm3a")
|
||||
self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
|
||||
# self.bar.set_diameter(200)
|
||||
|
||||
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
|
||||
# self.colormap = pg.GradientWidget()
|
||||
# self.d3.add_widget(self.colormap, row=0, col=0)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.figure.clear_all()
|
||||
self.figure.client.shutdown()
|
||||
self.dock.close()
|
||||
self.figure.cleanup()
|
||||
self.figure.close()
|
||||
self.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
@@ -136,17 +196,20 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
apply_theme("dark")
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(module_path, "assets", "app_icons", "terminal_icon.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
# qdarktheme.setup_theme("auto")
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
|
||||
app.setWindowIcon(icon)
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>2104</width>
|
||||
<height>966</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Plotting Console</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_1">
|
||||
<attribute name="title">
|
||||
<string>BECDock</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="dock_placeholder" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>BECFigure</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QWidget" name="glw" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QWidget" name="widget_console" native="true"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,9 +0,0 @@
|
||||
from .motor_control_compilations import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorControlPanelRelative,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
@@ -1,250 +0,0 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
import qdarktheme
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.motor_control.motor_control import MotorThread
|
||||
from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable
|
||||
from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import (
|
||||
MotorControlAbsolute,
|
||||
)
|
||||
from bec_widgets.widgets.motor_control.movement_relative.movement_relative import (
|
||||
MotorControlRelative,
|
||||
)
|
||||
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"motor_control": {
|
||||
"motor_x": "samx",
|
||||
"motor_y": "samy",
|
||||
"step_size_x": 3,
|
||||
"step_size_y": 3,
|
||||
"precision": 4,
|
||||
"step_x_y_same": False,
|
||||
"move_with_arrows": False,
|
||||
},
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorControlApp(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
# self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
# Create MotorCoordinateTable
|
||||
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
# splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
splitter.addWidget(self.motor_table)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
# lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
# )
|
||||
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
|
||||
self.motor_table.add_coordinate
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_table.set_precision
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_control_panel.absolute_widget.set_precision
|
||||
)
|
||||
|
||||
# self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
|
||||
|
||||
|
||||
class MotorControlMap(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
# self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
# splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
# lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
# )
|
||||
|
||||
|
||||
class MotorControlPanel(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
# self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
class MotorControlPanelAbsolute(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
|
||||
class MotorControlPanelRelative(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--variant",
|
||||
type=str,
|
||||
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
|
||||
help="Select the variant of the motor control to run. "
|
||||
"'app' for the full application, "
|
||||
"'map' for MotorMap, "
|
||||
"'panel' for the MotorControlPanel, "
|
||||
"'panel_abs' for MotorControlPanel with absolute control, "
|
||||
"'panel_rel' for MotorControlPanel with relative control.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
if args.variant == "app":
|
||||
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "map":
|
||||
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel":
|
||||
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_abs":
|
||||
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_rel":
|
||||
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
|
||||
else:
|
||||
print("Please specify a valid variant to run. Use -h for help.")
|
||||
print("Running the full application by default.")
|
||||
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
|
||||
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,926 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1561</width>
|
||||
<height>748</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>1409</width>
|
||||
<height>748</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Controller</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout" stretch="8,5,8">
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QFrame" name="Controls">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>221</width>
|
||||
<height>471</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
|
||||
<property name="spacing">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetMinimumSize</enum>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorSelection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>145</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Selection</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Motor Y</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_x"/>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="comboBox_motor_y"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Motor X</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="QPushButton" name="pushButton_connecMotors">
|
||||
<property name="text">
|
||||
<string>Connect Motors</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Minimum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>18</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>339</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Relative</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_enableArrows">
|
||||
<property name="text">
|
||||
<string>Move with arrow keys</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_same_xy">
|
||||
<property name="text">
|
||||
<string>Step [X] = Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="step_grid">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [X]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>2</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="direction_grid">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_up">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::UpArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_down">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::DownArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QToolButton" name="toolButton_left">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::LeftArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QToolButton" name="toolButton_right">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
<property name="arrowType">
|
||||
<enum>Qt::RightArrow</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Minimum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>18</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl_absolute">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Move Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_save_with_go">
|
||||
<property name="text">
|
||||
<string>Save position with Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_set">
|
||||
<property name="text">
|
||||
<string>Set</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_go_absolute">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_stop">
|
||||
<property name="text">
|
||||
<string>Stop Movement</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget_tables">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_coordinates">
|
||||
<attribute name="title">
|
||||
<string>Coordinates</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Entries Mode:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox_mode">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Individual</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Start/Stop</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_coordinates">
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::MultiSelection</enum>
|
||||
</property>
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Show</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Move</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Tag</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="pushButton_resize_table">
|
||||
<property name="text">
|
||||
<string>Resize Table</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QCheckBox" name="checkBox_resize_auto">
|
||||
<property name="text">
|
||||
<string>Resize Auto</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="pushButton_importCSV">
|
||||
<property name="text">
|
||||
<string>Import CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pushButton_exportCSV">
|
||||
<property name="text">
|
||||
<string>Export CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="pushButton_help">
|
||||
<property name="text">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_duplicate">
|
||||
<property name="text">
|
||||
<string>Duplicate Last Entry</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_settings">
|
||||
<attribute name="title">
|
||||
<string>Settings</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorLimits">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Limits</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_updateLimits">
|
||||
<property name="text">
|
||||
<string>Update</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_Y_max">
|
||||
<property name="text">
|
||||
<string>+ Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QLabel" name="label_Y_min">
|
||||
<property name="text">
|
||||
<string>- Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_X_min">
|
||||
<property name="text">
|
||||
<string>- X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_X_max">
|
||||
<property name="text">
|
||||
<string>+ X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Plotting Options</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="0" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_max_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>10000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>100</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>5000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_15">
|
||||
<property name="text">
|
||||
<string>Max Points</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_scatter_size">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>15</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>5</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>Scatter Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="3">
|
||||
<widget class="QPushButton" name="pushButton_update_config">
|
||||
<property name="text">
|
||||
<string>Update Settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_num_dim_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_16">
|
||||
<property name="text">
|
||||
<string>N dim</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0" colspan="3">
|
||||
<widget class="QPushButton" name="pushButton_enableGUI">
|
||||
<property name="text">
|
||||
<string>Enable Control GUI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_queue">
|
||||
<attribute name="title">
|
||||
<string>Queue</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Work in progress</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_5">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Reset Queue</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_2">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>queueID</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scan_id</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>is_scan</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>type</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scan_number</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>IQ status</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
18
bec_widgets/examples/plugin_example_pyside/main.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
|
||||
|
||||
import sys
|
||||
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = TicTacToe()
|
||||
window.state = "-X-XO----"
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoeplugin import TicTacToePlugin
|
||||
|
||||
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
|
||||
"tictactoetaskmenu.py"]
|
||||
}
|
||||
136
bec_widgets/examples/plugin_example_pyside/tictactoe.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
EMPTY = "-"
|
||||
CROSS = "X"
|
||||
NOUGHT = "O"
|
||||
DEFAULT_STATE = "---------"
|
||||
|
||||
|
||||
class TicTacToe(QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
print("TicTac HERE !!!!!!")
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def setState(self, new_state):
|
||||
self._turn_number = 0
|
||||
self._state = DEFAULT_STATE
|
||||
for position in range(min(9, len(new_state))):
|
||||
mark = new_state[position]
|
||||
if mark == CROSS or mark == NOUGHT:
|
||||
self._turn_number += 1
|
||||
self._change_state_at(position, mark)
|
||||
position += 1
|
||||
self.update()
|
||||
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@Slot()
|
||||
def clear_board(self):
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
self.update()
|
||||
|
||||
def _change_state_at(self, pos, new_state):
|
||||
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self._turn_number == 9:
|
||||
self.clear_board()
|
||||
return
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if cell.contains(event.position().toPoint()):
|
||||
if self._state[position] == EMPTY:
|
||||
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
|
||||
self._change_state_at(position, new_state)
|
||||
self._turn_number += 1
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
with QPainter(self) as painter:
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
painter.setPen(QPen(Qt.darkGreen, 1))
|
||||
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
|
||||
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
|
||||
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
|
||||
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
|
||||
|
||||
painter.setPen(QPen(Qt.darkBlue, 2))
|
||||
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if self._state[position] == CROSS:
|
||||
painter.drawLine(cell.topLeft(), cell.bottomRight())
|
||||
painter.drawLine(cell.topRight(), cell.bottomLeft())
|
||||
elif self._state[position] == NOUGHT:
|
||||
painter.drawEllipse(cell)
|
||||
|
||||
painter.setPen(QPen(Qt.yellow, 3))
|
||||
|
||||
for position in range(0, 8, 3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 1] == self._state[position]
|
||||
and self._state[position + 2] == self._state[position]
|
||||
):
|
||||
y = self._cell_rect(position).center().y()
|
||||
painter.drawLine(0, y, self.width(), y)
|
||||
self._turn_number = 9
|
||||
|
||||
for position in range(3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 3] == self._state[position]
|
||||
and self._state[position + 6] == self._state[position]
|
||||
):
|
||||
x = self._cell_rect(position).center().x()
|
||||
painter.drawLine(x, 0, x, self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[0] != EMPTY
|
||||
and self._state[4] == self._state[0]
|
||||
and self._state[8] == self._state[0]
|
||||
):
|
||||
painter.drawLine(0, 0, self.width(), self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[2] != EMPTY
|
||||
and self._state[4] == self._state[2]
|
||||
and self._state[6] == self._state[2]
|
||||
):
|
||||
painter.drawLine(0, self.height(), self.width(), 0)
|
||||
self._turn_number = 9
|
||||
|
||||
def _cell_rect(self, position):
|
||||
h_margin = self.width() / 30
|
||||
v_margin = self.height() / 30
|
||||
row = int(position / 3)
|
||||
column = position - 3 * row
|
||||
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
|
||||
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
|
||||
return QRect(pos, size)
|
||||
|
||||
def _cell_width(self):
|
||||
return self.width() / 3
|
||||
|
||||
def _cell_height(self):
|
||||
return self.height() / 3
|
||||
|
||||
state = Property(str, state, setState)
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu import TicTacToeTaskMenuFactory
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='TicTacToe' name='ticTacToe'>
|
||||
<property name='geometry'>
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>200</width>
|
||||
<height>200</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name='state'>
|
||||
<string>-X-XO----</string>
|
||||
</property>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = TicTacToe(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Games"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon("sports_esports")
|
||||
|
||||
def includeFile(self):
|
||||
return "tictactoe"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
manager = form_editor.extensionManager()
|
||||
iid = TicTacToeTaskMenuFactory.task_menu_iid()
|
||||
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "TicTacToe"
|
||||
|
||||
def toolTip(self):
|
||||
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class TicTacToeDialog(QDialog): # pragma: no cover
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self._ticTacToe = TicTacToe(self)
|
||||
layout.addWidget(self._ticTacToe)
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
|
||||
)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
reset_button = button_box.button(QDialogButtonBox.Reset)
|
||||
reset_button.clicked.connect(self._ticTacToe.clear_board)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def set_state(self, new_state):
|
||||
self._ticTacToe.setState(new_state)
|
||||
|
||||
def state(self):
|
||||
return self._ticTacToe.state
|
||||
|
||||
|
||||
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
|
||||
def __init__(self, ticTacToe, parent):
|
||||
super().__init__(parent)
|
||||
self._ticTacToe = ticTacToe
|
||||
self._edit_state_action = QAction("Edit State...", None)
|
||||
self._edit_state_action.triggered.connect(self._edit_state)
|
||||
|
||||
def taskActions(self):
|
||||
return [self._edit_state_action]
|
||||
|
||||
def preferredEditAction(self):
|
||||
return self._edit_state_action
|
||||
|
||||
@Slot()
|
||||
def _edit_state(self):
|
||||
dialog = TicTacToeDialog(self._ticTacToe)
|
||||
dialog.set_state(self._ticTacToe.state)
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
self._ticTacToe.state = dialog.state()
|
||||
|
||||
|
||||
class TicTacToeTaskMenuFactory(QExtensionFactory):
|
||||
def __init__(self, extension_manager):
|
||||
super().__init__(extension_manager)
|
||||
|
||||
@staticmethod
|
||||
def task_menu_iid():
|
||||
return "org.qt-project.Qt.Designer.TaskMenu"
|
||||
|
||||
def createExtension(self, object, iid, parent):
|
||||
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
|
||||
return None
|
||||
if object.__class__.__name__ != "TicTacToe":
|
||||
return None
|
||||
return TicTacToeTaskMenu(object, parent)
|
||||
214
bec_widgets/qt_utils/error_popups.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
|
||||
'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
|
||||
"""
|
||||
popup_error = bool(slot_kwargs.pop("popup_error", False))
|
||||
|
||||
def error_managed(method):
|
||||
@Slot(*slot_args, **slot_kwargs)
|
||||
@functools.wraps(method)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except Exception:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
|
||||
|
||||
return wrapper
|
||||
|
||||
return error_managed
|
||||
|
||||
|
||||
class WarningPopupUtility(QObject):
|
||||
"""
|
||||
Utility class to show warning popups in the application.
|
||||
"""
|
||||
|
||||
@SafeSlot(str, str, str, QWidget)
|
||||
def show_warning_message(self, title, message, detailed_text, widget):
|
||||
msg = QMessageBox(widget)
|
||||
msg.setIcon(QMessageBox.Warning)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(message)
|
||||
msg.setStandardButtons(QMessageBox.Ok)
|
||||
msg.setDetailedText(detailed_text)
|
||||
msg.exec_()
|
||||
|
||||
def show_warning(self, title: str, message: str, detailed_text: str, widget: QWidget = None):
|
||||
"""
|
||||
Show a warning message with the given title, message, and detailed text.
|
||||
|
||||
Args:
|
||||
title (str): The title of the warning message.
|
||||
message (str): The main text of the warning message.
|
||||
detailed_text (str): The detailed text to show when the user expands the message.
|
||||
widget (QWidget): The parent widget for the message box.
|
||||
"""
|
||||
self.show_warning_message(title, message, detailed_text, widget)
|
||||
|
||||
|
||||
_popup_utility_instance = None
|
||||
|
||||
|
||||
class _ErrorPopupUtility(QObject):
|
||||
"""
|
||||
Utility class to manage error popups in the application to show error messages to the users.
|
||||
This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
|
||||
"""
|
||||
|
||||
error_occurred = Signal(str, str, QWidget)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.error_occurred.connect(self.show_error_message)
|
||||
self.enable_error_popup = False
|
||||
self._initialized = True
|
||||
sys.excepthook = self.custom_exception_hook
|
||||
|
||||
@SafeSlot(str, str, QWidget)
|
||||
def show_error_message(self, title, message, widget):
|
||||
detailed_text = self.format_traceback(message)
|
||||
error_message = self.parse_error_message(detailed_text)
|
||||
|
||||
msg = QMessageBox(widget)
|
||||
msg.setIcon(QMessageBox.Critical)
|
||||
msg.setWindowTitle(title)
|
||||
msg.setText(error_message)
|
||||
msg.setStandardButtons(QMessageBox.Ok)
|
||||
msg.setDetailedText(detailed_text)
|
||||
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
msg.setMinimumWidth(600)
|
||||
msg.setMinimumHeight(400)
|
||||
msg.exec_()
|
||||
|
||||
def format_traceback(self, traceback_message: str) -> str:
|
||||
"""
|
||||
Format the traceback message to be displayed in the error popup by adding indentation to each line.
|
||||
|
||||
Args:
|
||||
traceback_message(str): The traceback message to be formatted.
|
||||
|
||||
Returns:
|
||||
str: The formatted traceback message.
|
||||
"""
|
||||
formatted_lines = []
|
||||
lines = traceback_message.split("\n")
|
||||
for line in lines:
|
||||
formatted_lines.append(" " + line) # Add indentation to each line
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
def parse_error_message(self, traceback_message):
|
||||
lines = traceback_message.split("\n")
|
||||
error_message = "Error occurred. See details."
|
||||
capture = False
|
||||
captured_message = []
|
||||
|
||||
for line in lines:
|
||||
if "raise" in line:
|
||||
capture = True
|
||||
continue
|
||||
if capture:
|
||||
if line.strip() and not line.startswith(" File "):
|
||||
captured_message.append(line.strip())
|
||||
else:
|
||||
break
|
||||
|
||||
if captured_message:
|
||||
error_message = " ".join(captured_message)
|
||||
return error_message
|
||||
|
||||
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
|
||||
if popup_error or self.enable_error_popup:
|
||||
error_message = traceback.format_exception(exctype, value, tb)
|
||||
self.error_occurred.emit(
|
||||
"Method error" if popup_error else "Application Error",
|
||||
"".join(error_message),
|
||||
self.parent(),
|
||||
)
|
||||
else:
|
||||
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
|
||||
|
||||
def enable_global_error_popups(self, state: bool):
|
||||
"""
|
||||
Enable or disable global error popups for all applications.
|
||||
|
||||
Args:
|
||||
state(bool): True to enable error popups, False to disable error popups.
|
||||
"""
|
||||
self.enable_error_popup = bool(state)
|
||||
|
||||
|
||||
def ErrorPopupUtility():
|
||||
global _popup_utility_instance
|
||||
if not _popup_utility_instance:
|
||||
_popup_utility_instance = _ErrorPopupUtility()
|
||||
return _popup_utility_instance
|
||||
|
||||
|
||||
class ExampleWidget(QWidget): # pragma: no cover
|
||||
"""
|
||||
Example widget to demonstrate error handling with the ErrorPopupUtility.
|
||||
|
||||
Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.init_ui()
|
||||
self.warning_utility = WarningPopupUtility(self)
|
||||
|
||||
def init_ui(self):
|
||||
self.layout = QVBoxLayout(self)
|
||||
|
||||
# Button to trigger method with error handling
|
||||
self.error_button = QPushButton("Trigger Handled Error", self)
|
||||
self.error_button.clicked.connect(self.method_with_error_handling)
|
||||
self.layout.addWidget(self.error_button)
|
||||
|
||||
# Button to trigger method without error handling
|
||||
self.normal_button = QPushButton("Trigger Normal Error", self)
|
||||
self.normal_button.clicked.connect(self.method_without_error_handling)
|
||||
self.layout.addWidget(self.normal_button)
|
||||
|
||||
# Button to trigger warning popup
|
||||
self.warning_button = QPushButton("Trigger Warning", self)
|
||||
self.warning_button.clicked.connect(self.trigger_warning)
|
||||
self.layout.addWidget(self.warning_button)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def method_with_error_handling(self):
|
||||
"""This method raises an error and the exception is handled by the decorator."""
|
||||
raise ValueError("This is a handled error.")
|
||||
|
||||
@SafeSlot()
|
||||
def method_without_error_handling(self):
|
||||
"""This method raises an error and the exception is not handled here."""
|
||||
raise ValueError("This is an unhandled error.")
|
||||
|
||||
@SafeSlot()
|
||||
def trigger_warning(self):
|
||||
"""Trigger a warning using the WarningPopupUtility."""
|
||||
self.warning_utility.show_warning(
|
||||
title="Warning",
|
||||
message="This is a warning message.",
|
||||
detailed_text="This is the detailed text of the warning message.",
|
||||
widget=self,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ExampleWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
47
bec_widgets/qt_utils/redis_message_waiter.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from bec_lib.serialization import MsgpackSerialization
|
||||
from bec_lib.utils import lazy_import_from
|
||||
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
|
||||
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
|
||||
class QtRedisMessageWaiter:
|
||||
def __init__(self, redis_connector, message_to_wait):
|
||||
self.ev_loop = QEventLoop()
|
||||
self.response = None
|
||||
self.connector = redis_connector
|
||||
self.message_to_wait = message_to_wait
|
||||
self.pubsub = redis_connector._redis_conn.pubsub()
|
||||
self.pubsub.subscribe(self.message_to_wait.endpoint)
|
||||
fd = self.pubsub.connection._sock.fileno()
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._pubsub_readable)
|
||||
|
||||
def _msg_received(self, msg_obj):
|
||||
self.response = msg_obj.value
|
||||
self.ev_loop.quit()
|
||||
|
||||
def wait(self, timeout=1):
|
||||
timer = QTimer()
|
||||
timer.singleShot(timeout * 1000, self.ev_loop.quit)
|
||||
self.ev_loop.exec_()
|
||||
timer.stop()
|
||||
self.notifier.setEnabled(False)
|
||||
self.pubsub.close()
|
||||
return self.response
|
||||
|
||||
def _pubsub_readable(self, fd):
|
||||
while True:
|
||||
msg = self.pubsub.get_message()
|
||||
if msg:
|
||||
if msg["type"] == "subscribe":
|
||||
# get_message buffers, so we may already have the answer
|
||||
# let's check...
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
return
|
||||
channel = msg["channel"].decode()
|
||||
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
|
||||
self.connector._execute_callback(self._msg_received, msg, {})
|
||||
119
bec_widgets/qt_utils/settings_dialog.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class SettingWidget(QWidget):
|
||||
"""
|
||||
Abstract class for a settings widget to enforce the implementation of the accept_changes and display_current_settings.
|
||||
Can be used for toolbar actions to display the settings of a widget.
|
||||
|
||||
Args:
|
||||
target_widget (QWidget): The widget that the settings will be taken from and applied to.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self.target_widget = None
|
||||
|
||||
def set_target_widget(self, target_widget: QWidget):
|
||||
self.target_widget = target_widget
|
||||
|
||||
@Slot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Accepts the changes made in the settings widget and applies them to the target widget.
|
||||
"""
|
||||
pass
|
||||
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, config_dict: dict):
|
||||
"""
|
||||
Displays the current settings of the target widget in the settings widget.
|
||||
|
||||
Args:
|
||||
config_dict(dict): The current settings of the target widget.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""
|
||||
Dialog to display and edit the settings of a widget with accept and cancel buttons.
|
||||
|
||||
Args:
|
||||
parent (QWidget): The parent widget of the dialog.
|
||||
target_widget (QWidget): The widget that the settings will be taken from and applied to.
|
||||
settings_widget (SettingWidget): The widget that will display the settings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
settings_widget: SettingWidget = None,
|
||||
window_title: str = "Settings",
|
||||
config: dict = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self.setModal(False)
|
||||
|
||||
self.setWindowTitle(window_title)
|
||||
|
||||
self.widget = settings_widget
|
||||
self.widget.set_target_widget(parent)
|
||||
if config is None:
|
||||
config = parent.get_config()
|
||||
|
||||
self.widget.display_current_settings(config)
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
|
||||
self.apply_button = QPushButton("Apply")
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Cancel))
|
||||
button_layout.addWidget(self.apply_button)
|
||||
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Ok))
|
||||
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
self.apply_button.clicked.connect(self.apply_changes)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.layout.addWidget(self.widget)
|
||||
self.layout.addLayout(button_layout)
|
||||
|
||||
ok_button = self.button_box.button(QDialogButtonBox.Ok)
|
||||
ok_button.setDefault(True)
|
||||
ok_button.setAutoDefault(True)
|
||||
|
||||
@Slot()
|
||||
def accept(self):
|
||||
"""
|
||||
Accept the changes made in the settings widget and close the dialog.
|
||||
"""
|
||||
self.widget.accept_changes()
|
||||
super().accept()
|
||||
|
||||
@Slot()
|
||||
def apply_changes(self):
|
||||
"""
|
||||
Apply the changes made in the settings widget without closing the dialog.
|
||||
"""
|
||||
self.widget.accept_changes()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dialog.
|
||||
"""
|
||||
self.button_box.close()
|
||||
self.button_box.deleteLater()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.cleanup()
|
||||
super().closeEvent(event)
|
||||
227
bec_widgets/qt_utils/toolbar.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from typing import Literal
|
||||
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QAction, QColor, QIcon
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMenu, QToolBar, QToolButton, QWidget
|
||||
|
||||
import bec_widgets
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
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 (bool, 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.
|
||||
"""
|
||||
|
||||
|
||||
class SeparatorAction(ToolBarAction):
|
||||
"""Separator action for the toolbar."""
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
toolbar.addSeparator()
|
||||
|
||||
|
||||
class IconAction(ToolBarAction):
|
||||
"""
|
||||
Action with an icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_path (str): The path to the icon file.
|
||||
tooltip (str): The tooltip for the action.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
|
||||
super().__init__(icon_path, tooltip, checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
icon = QIcon()
|
||||
icon.addFile(self.icon_path, size=QSize(20, 20))
|
||||
self.action = QAction(icon, self.tooltip, target)
|
||||
self.action.setCheckable(self.checkable)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
|
||||
class MaterialIconAction:
|
||||
"""
|
||||
Action with a Material icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (bool, 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.
|
||||
"""
|
||||
|
||||
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,
|
||||
):
|
||||
self.icon_name = icon_name
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
icon = self.get_icon()
|
||||
self.action = QAction(icon, self.tooltip, target)
|
||||
self.action.setCheckable(self.checkable)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
|
||||
icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
)
|
||||
return 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, device_combobox):
|
||||
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()
|
||||
layout = QHBoxLayout(widget)
|
||||
label = QLabel(f"{self.label}")
|
||||
layout.addWidget(label)
|
||||
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 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
|
||||
self.widgets = defaultdict(dict)
|
||||
|
||||
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_id, action in self.actions.items():
|
||||
sub_action = QAction(action.tooltip, target)
|
||||
if hasattr(action, "icon_path"):
|
||||
icon = QIcon()
|
||||
icon.addFile(action.icon_path, size=QSize(20, 20))
|
||||
sub_action.setIcon(icon)
|
||||
elif hasattr(action, "get_icon"):
|
||||
sub_action.setIcon(action.get_icon())
|
||||
sub_action.setCheckable(action.checkable)
|
||||
menu.addAction(sub_action)
|
||||
self.widgets[action_id] = sub_action
|
||||
button.setMenu(menu)
|
||||
toolbar.addWidget(button)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.widgets = defaultdict(dict)
|
||||
self.set_background_color()
|
||||
|
||||
if actions is not None and target_widget is not None:
|
||||
self.populate_toolbar(actions, target_widget)
|
||||
|
||||
def populate_toolbar(self, actions: dict, target_widget):
|
||||
"""Populates the toolbar with a set of actions.
|
||||
|
||||
Args:
|
||||
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
|
||||
target_widget (QWidget): The widget that the actions will target.
|
||||
"""
|
||||
self.clear()
|
||||
for action_id, action in actions.items():
|
||||
action.add_to_toolbar(self, target_widget)
|
||||
self.widgets[action_id] = action
|
||||
|
||||
def set_background_color(self):
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")
|
||||
@@ -1,3 +1,5 @@
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .bec_connector import BECConnector, ConnectionConfig
|
||||
from .bec_dispatcher import BECDispatcher
|
||||
from .bec_table import BECTable
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Type
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
import yaml
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
@@ -20,8 +27,10 @@ class ConnectionConfig(BaseModel):
|
||||
gui_id: Optional[str] = Field(
|
||||
default=None, validate_default=True, description="The GUI ID of the widget."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("gui_id")
|
||||
@classmethod
|
||||
def generate_gui_id(cls, v, values):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
@@ -31,16 +40,55 @@ class ConnectionConfig(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class BECConnector:
|
||||
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
|
||||
class WorkerSignals(QObject):
|
||||
progress = Signal(dict)
|
||||
completed = Signal()
|
||||
|
||||
USER_ACCESS = ["config_dict", "get_all_rpc"]
|
||||
|
||||
class Worker(QRunnable):
|
||||
"""
|
||||
Worker class to run a function in a separate thread.
|
||||
"""
|
||||
|
||||
def __init__(self, func, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.signals = WorkerSignals()
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the specified function in the thread.
|
||||
"""
|
||||
self.func(*self.args, **self.kwargs)
|
||||
self.signals.completed.emit()
|
||||
|
||||
|
||||
class BECConnector:
|
||||
"""Connection mixin class to handle BEC client and device manager"""
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
|
||||
EXIT_HANDLERS = {}
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@pyqtSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
print("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
print("Shutting down BEC Client", repr(client))
|
||||
client.shutdown()
|
||||
|
||||
BECConnector.EXIT_HANDLERS[self.client] = terminate
|
||||
QApplication.instance().aboutToQuit.connect(terminate)
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
self.config.widget_class = self.__class__.__name__
|
||||
@@ -58,26 +106,68 @@ class BECConnector:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# register widget to rpc register
|
||||
# be careful: when registering, and the object is not a BECWidget,
|
||||
# cleanup has to called manually since there is no 'closeEvent'
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
def get_all_rpc(self) -> dict:
|
||||
# Error popups
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
|
||||
Use this method if you want to wait for a task to complete without blocking the
|
||||
main thread.
|
||||
|
||||
Args:
|
||||
fn: Function to run in a separate thread.
|
||||
*args: Arguments for the function.
|
||||
on_complete: Slot to run when the task is complete.
|
||||
**kwargs: Keyword arguments for the function.
|
||||
|
||||
Returns:
|
||||
worker: The worker object that will run the task.
|
||||
|
||||
Examples:
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> self.submit_task(my_function, 1, 2)
|
||||
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> def on_complete():
|
||||
>>> print("Task complete")
|
||||
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
|
||||
|
||||
"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
if on_complete:
|
||||
worker.signals.completed.connect(on_complete)
|
||||
self._thread_pool.start(worker)
|
||||
return worker
|
||||
|
||||
def _get_all_rpc(self) -> dict:
|
||||
"""Get all registered RPC objects."""
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
return dict(all_connections)
|
||||
|
||||
@property
|
||||
def rpc_id(self) -> str:
|
||||
def _rpc_id(self) -> str:
|
||||
"""Get the RPC ID of the widget."""
|
||||
return self.gui_id
|
||||
|
||||
@rpc_id.setter
|
||||
def rpc_id(self, rpc_id: str) -> None:
|
||||
@_rpc_id.setter
|
||||
def _rpc_id(self, rpc_id: str) -> None:
|
||||
"""Set the RPC ID of the widget."""
|
||||
self.gui_id = rpc_id
|
||||
|
||||
@property
|
||||
def config_dict(self) -> dict:
|
||||
def _config_dict(self) -> dict:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -86,8 +176,8 @@ class BECConnector:
|
||||
"""
|
||||
return self.config.model_dump()
|
||||
|
||||
@config_dict.setter
|
||||
def config_dict(self, config: BaseModel) -> None:
|
||||
@_config_dict.setter
|
||||
def _config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
@@ -96,6 +186,60 @@ class BECConnector:
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
|
||||
Args:
|
||||
config(dict): Configuration settings.
|
||||
generate_new_id(bool): If True, generate a new GUI ID for the widget.
|
||||
"""
|
||||
self.config = ConnectionConfig(**config)
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
config = load_yaml_gui(self)
|
||||
else:
|
||||
config = load_yaml(path)
|
||||
|
||||
if config is not None:
|
||||
if config.get("widget_class") != self.__class__.__name__:
|
||||
raise ValueError(
|
||||
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
|
||||
)
|
||||
self.apply_config(config)
|
||||
|
||||
def save_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
save_yaml_gui(self, self._config_dict)
|
||||
else:
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
@@ -156,15 +300,3 @@ class BECConnector:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
if len(all_connections) == 0:
|
||||
print("No more connections. Shutting down GUI BEC client.")
|
||||
self.client.shutdown()
|
||||
|
||||
# def closeEvent(self, event):
|
||||
# self.cleanup()
|
||||
# super().closeEvent(event)
|
||||
|
||||
153
bec_widgets/utils/bec_designer.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
|
||||
|
||||
def designer_material_icon(icon_name: str) -> QIcon:
|
||||
"""
|
||||
Create a QIcon for the BECDesigner with the given material icon name.
|
||||
|
||||
Args:
|
||||
icon_name (str): The name of the material icon.
|
||||
|
||||
Returns:
|
||||
QIcon: The QIcon for the material icon.
|
||||
"""
|
||||
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
|
||||
|
||||
|
||||
def list_editable_packages() -> set[str]:
|
||||
"""
|
||||
List all editable packages in the environment.
|
||||
|
||||
Returns:
|
||||
set: A set of paths to editable packages.
|
||||
"""
|
||||
|
||||
editable_packages = set()
|
||||
|
||||
# Get site-packages directories
|
||||
site_packages = site.getsitepackages()
|
||||
if hasattr(site, "getusersitepackages"):
|
||||
site_packages.append(site.getusersitepackages())
|
||||
|
||||
for dist in importlib.metadata.distributions():
|
||||
location = dist.locate_file("").resolve()
|
||||
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
|
||||
|
||||
if is_editable:
|
||||
editable_packages.add(str(location))
|
||||
|
||||
for packages in site_packages:
|
||||
# all dist-info directories in site-packages that contain a direct_url.json file
|
||||
dist_info_dirs = Path(packages).rglob("*.dist-info")
|
||||
for dist_info_dir in dist_info_dirs:
|
||||
direct_url = dist_info_dir / "direct_url.json"
|
||||
if not direct_url.exists():
|
||||
continue
|
||||
# load the json file and get the path to the package
|
||||
with open(direct_url, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
path = data.get("url", "")
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
editable_packages.add(path)
|
||||
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
|
||||
init_virtual_env()
|
||||
|
||||
major_version = sys.version_info[0]
|
||||
minor_version = sys.version_info[1]
|
||||
os.environ["PY_MAJOR_VERSION"] = str(major_version)
|
||||
os.environ["PY_MINOR_VERSION"] = str(minor_version)
|
||||
|
||||
if sys.platform == "win32":
|
||||
if is_virtual_env():
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
suffix = f"{sys.abiflags}.so"
|
||||
env_var = "LD_PRELOAD"
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
"""
|
||||
Recursively find all directories containing a .pyproject file.
|
||||
"""
|
||||
plugin_paths = []
|
||||
for path in base_path.rglob("*.pyproject"):
|
||||
plugin_paths.append(str(path.parent))
|
||||
return plugin_paths
|
||||
|
||||
|
||||
def set_plugin_environment_variable(plugin_paths):
|
||||
"""
|
||||
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
|
||||
"""
|
||||
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
|
||||
if current_paths:
|
||||
current_paths = current_paths.split(os.pathsep)
|
||||
else:
|
||||
current_paths = []
|
||||
|
||||
current_paths.extend(plugin_paths)
|
||||
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def main(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
|
||||
plugin_paths = find_plugin_paths(base_dir)
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
@@ -66,6 +65,11 @@ class QtRedisConnector(RedisConnector):
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
class BECClientWithoutLoggerInit(BECClient):
|
||||
def _initialize_logger(self):
|
||||
return
|
||||
|
||||
|
||||
class BECDispatcher:
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
@@ -78,7 +82,7 @@ class BECDispatcher:
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, client=None, config: str = None):
|
||||
def __init__(self, client=None, config: str | ServiceConfig = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
@@ -87,13 +91,16 @@ class BECDispatcher:
|
||||
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
host, port = config.split(":")
|
||||
redis_config = {"host": host, "port": port}
|
||||
self.client = BECClient(
|
||||
config=ServiceConfig(redis=redis_config), connector_cls=QtRedisConnector
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClientWithoutLoggerInit(
|
||||
config=config, connector_cls=QtRedisConnector
|
||||
) # , forced=True)
|
||||
else:
|
||||
self.client = BECClient(connector_cls=QtRedisConnector) # , forced=True)
|
||||
self.client = BECClientWithoutLoggerInit(
|
||||
connector_cls=QtRedisConnector
|
||||
) # , forced=True)
|
||||
else:
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
@@ -113,9 +120,12 @@ class BECDispatcher:
|
||||
cls._initialized = False
|
||||
|
||||
def connect_slot(
|
||||
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
@@ -123,11 +133,18 @@ class BECDispatcher:
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics(Union[str, list]): The topic(s) to disconnect 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
|
||||
@@ -138,11 +155,17 @@ class BECDispatcher:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].difference_update(set(topics_str))
|
||||
if not self._slots[slot]:
|
||||
del self._slots[slot]
|
||||
self._slots[connected_slot].difference_update(set(topics_str))
|
||||
if not self._slots[connected_slot]:
|
||||
del self._slots[connected_slot]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect all slots from a topic.
|
||||
|
||||
Args:
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
for slot in list(self._slots.keys()):
|
||||
@@ -152,4 +175,11 @@ class BECDispatcher:
|
||||
del self._slots[slot]
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
Disconnect all slots from all topics.
|
||||
|
||||
Args:
|
||||
*args: Arbitrary positional arguments
|
||||
**kwargs: Arbitrary keyword arguments
|
||||
"""
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
|
||||
32
bec_widgets/utils/bec_widget.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
|
||||
class BECWidget(BECConnector):
|
||||
"""Mixin class for all BEC widgets, to handle cleanup"""
|
||||
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
if not isinstance(self, QWidget):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
super().__init__(client, config, gui_id)
|
||||
|
||||
# Set the theme to auto if it is not set yet
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
set_theme("dark")
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.rpc_register.remove_rpc(self)
|
||||
try:
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event)
|
||||
@@ -1,11 +1,68 @@
|
||||
import itertools
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import bec_qthemes
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
theme = "dark"
|
||||
else:
|
||||
theme = QApplication.instance().theme["theme"]
|
||||
return bec_qthemes.load_palette(theme)
|
||||
|
||||
|
||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||
"""
|
||||
Set the theme for the application.
|
||||
|
||||
Args:
|
||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
bec_qthemes.setup_theme(theme)
|
||||
pg.setConfigOption("background", "w" if app.theme["theme"] == "light" else "k")
|
||||
apply_theme(theme)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
if theme != "auto":
|
||||
return
|
||||
|
||||
def callback():
|
||||
app.theme["theme"] = listener._theme.lower()
|
||||
apply_theme(listener._theme.lower())
|
||||
|
||||
listener = OSThemeSwitchListener(callback)
|
||||
|
||||
app.installEventFilter(listener)
|
||||
|
||||
|
||||
def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# go through all pyqtgraph widgets and set background
|
||||
children = itertools.chain.from_iterable(
|
||||
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
|
||||
)
|
||||
for pg_widget in children:
|
||||
pg_widget.setBackground("k" if theme == "dark" else "w")
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
app.setStyleSheet(style)
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
@@ -52,8 +109,19 @@ class Colors:
|
||||
angles = Colors.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = []
|
||||
for ii in color_selection[:num]:
|
||||
color = cmap_colors[int(ii)]
|
||||
ii = 0
|
||||
while len(colors) < num:
|
||||
color_index = int(color_selection[ii])
|
||||
color = cmap_colors[color_index]
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme") and app.theme["theme"] == "light":
|
||||
background = 255
|
||||
else:
|
||||
background = 0
|
||||
if np.abs(np.mean(color[:3] * 255) - background) < 50:
|
||||
ii += 1
|
||||
continue
|
||||
|
||||
if format.upper() == "HEX":
|
||||
colors.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
@@ -62,4 +130,251 @@ class Colors:
|
||||
colors.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
ii += 1
|
||||
return colors
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
"""
|
||||
Convert HEX color to RGBA.
|
||||
|
||||
Args:
|
||||
hex_color(str): HEX color string.
|
||||
alpha(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
tuple: RGBA color tuple (r, g, b, a).
|
||||
"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
|
||||
return (r, g, b, a)
|
||||
else:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
Validate the color input if it is HEX or RGBA compatible. Can be used in any pydantic model as a field validator.
|
||||
|
||||
Args:
|
||||
color(tuple|str): The color to be validated. Can be a tuple of RGBA values or a HEX string.
|
||||
|
||||
Returns:
|
||||
tuple|str: The validated color.
|
||||
"""
|
||||
CSS_COLOR_NAMES = {
|
||||
"aliceblue",
|
||||
"antiquewhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedalmond",
|
||||
"blue",
|
||||
"blueviolet",
|
||||
"brown",
|
||||
"burlywood",
|
||||
"cadetblue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerblue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkblue",
|
||||
"darkcyan",
|
||||
"darkgoldenrod",
|
||||
"darkgray",
|
||||
"darkgreen",
|
||||
"darkgrey",
|
||||
"darkkhaki",
|
||||
"darkmagenta",
|
||||
"darkolivegreen",
|
||||
"darkorange",
|
||||
"darkorchid",
|
||||
"darkred",
|
||||
"darksalmon",
|
||||
"darkseagreen",
|
||||
"darkslateblue",
|
||||
"darkslategray",
|
||||
"darkslategrey",
|
||||
"darkturquoise",
|
||||
"darkviolet",
|
||||
"deeppink",
|
||||
"deepskyblue",
|
||||
"dimgray",
|
||||
"dimgrey",
|
||||
"dodgerblue",
|
||||
"firebrick",
|
||||
"floralwhite",
|
||||
"forestgreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostwhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"green",
|
||||
"greenyellow",
|
||||
"grey",
|
||||
"honeydew",
|
||||
"hotpink",
|
||||
"indianred",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderblush",
|
||||
"lawngreen",
|
||||
"lemonchiffon",
|
||||
"lightblue",
|
||||
"lightcoral",
|
||||
"lightcyan",
|
||||
"lightgoldenrodyellow",
|
||||
"lightgray",
|
||||
"lightgreen",
|
||||
"lightgrey",
|
||||
"lightpink",
|
||||
"lightsalmon",
|
||||
"lightseagreen",
|
||||
"lightskyblue",
|
||||
"lightslategray",
|
||||
"lightslategrey",
|
||||
"lightsteelblue",
|
||||
"lightyellow",
|
||||
"lime",
|
||||
"limegreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"mediumaquamarine",
|
||||
"mediumblue",
|
||||
"mediumorchid",
|
||||
"mediumpurple",
|
||||
"mediumseagreen",
|
||||
"mediumslateblue",
|
||||
"mediumspringgreen",
|
||||
"mediumturquoise",
|
||||
"mediumvioletred",
|
||||
"midnightblue",
|
||||
"mintcream",
|
||||
"mistyrose",
|
||||
"moccasin",
|
||||
"navajowhite",
|
||||
"navy",
|
||||
"oldlace",
|
||||
"olive",
|
||||
"olivedrab",
|
||||
"orange",
|
||||
"orangered",
|
||||
"orchid",
|
||||
"palegoldenrod",
|
||||
"palegreen",
|
||||
"paleturquoise",
|
||||
"palevioletred",
|
||||
"papayawhip",
|
||||
"peachpuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderblue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosybrown",
|
||||
"royalblue",
|
||||
"saddlebrown",
|
||||
"salmon",
|
||||
"sandybrown",
|
||||
"seagreen",
|
||||
"seashell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyblue",
|
||||
"slateblue",
|
||||
"slategray",
|
||||
"slategrey",
|
||||
"snow",
|
||||
"springgreen",
|
||||
"steelblue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whitesmoke",
|
||||
"yellow",
|
||||
"yellowgreen",
|
||||
}
|
||||
if isinstance(color, str):
|
||||
hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
|
||||
if hex_pattern.match(color):
|
||||
return color
|
||||
elif color.lower() in CSS_COLOR_NAMES:
|
||||
return color
|
||||
else:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a valid HEX string or CSS Color.",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
elif isinstance(color, tuple):
|
||||
if len(color) != 4:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a tuple of 4 elements (R, G, B, A).",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
for value in color:
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
f"The color values must be between 0 and 255 in RGBA format (R,G,B,A)",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
return color
|
||||
|
||||
@staticmethod
|
||||
def validate_color_map(color_map: str) -> str:
|
||||
"""
|
||||
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
|
||||
|
||||
Args:
|
||||
color_map(str): The colormap to be validated.
|
||||
|
||||
Returns:
|
||||
str: The validated colormap.
|
||||
"""
|
||||
available_colormaps = pg.colormap.listMaps()
|
||||
if color_map not in available_colormaps:
|
||||
raise PydanticCustomError(
|
||||
"unsupported colormap",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
return color_map
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Type
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
# from qtpy.QtCore import QObject, pyqtSignal
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import QObject, Qt
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
positionChanged = pyqtSignal(tuple)
|
||||
positionClicked = pyqtSignal(tuple)
|
||||
# Signal for 1D plot
|
||||
coordinatesChanged1D = pyqtSignal(tuple)
|
||||
coordinatesClicked1D = pyqtSignal(tuple)
|
||||
@@ -26,10 +30,13 @@ class Crosshair(QObject):
|
||||
super().__init__(parent)
|
||||
self.is_log_y = None
|
||||
self.is_log_x = None
|
||||
self.is_derivative = None
|
||||
self.plot_item = plot_item
|
||||
self.precision = precision
|
||||
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)
|
||||
self.h_line.skip_auto_range = True
|
||||
self.plot_item.addItem(self.v_line, ignoreBounds=True)
|
||||
self.plot_item.addItem(self.h_line, ignoreBounds=True)
|
||||
self.proxy = pg.SignalProxy(
|
||||
@@ -37,74 +44,75 @@ class Crosshair(QObject):
|
||||
)
|
||||
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
|
||||
|
||||
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
|
||||
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
|
||||
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
|
||||
|
||||
# Initialize markers
|
||||
self.marker_moved_1d = []
|
||||
self.marker_clicked_1d = []
|
||||
self.marker_moved_1d = {}
|
||||
self.marker_clicked_1d = {}
|
||||
self.marker_2d = None
|
||||
self.update_markers()
|
||||
|
||||
def update_markers(self):
|
||||
"""Update the markers for the crosshair, creating new ones if necessary."""
|
||||
|
||||
# Clear existing markers
|
||||
for marker in self.marker_moved_1d + self.marker_clicked_1d:
|
||||
self.plot_item.removeItem(marker)
|
||||
if self.marker_2d:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
|
||||
# Create new markers
|
||||
self.marker_moved_1d = []
|
||||
self.marker_clicked_1d = []
|
||||
self.marker_2d = None
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem): # 1D plot
|
||||
if item.name() in self.marker_moved_1d:
|
||||
continue
|
||||
pen = item.opts["pen"]
|
||||
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
|
||||
marker_moved = pg.ScatterPlotItem(
|
||||
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
|
||||
)
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
|
||||
)
|
||||
self.marker_moved_1d.append(marker_moved)
|
||||
marker_moved.skip_auto_range = True
|
||||
self.marker_moved_1d[item.name()] = marker_moved
|
||||
self.plot_item.addItem(marker_moved)
|
||||
|
||||
# Create glowing effect markers for clicked events
|
||||
marker_clicked_list = []
|
||||
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=size,
|
||||
pen=pg.mkPen(None),
|
||||
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
|
||||
)
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
marker_clicked.skip_auto_range = True
|
||||
self.marker_clicked_1d[item.name()] = marker_clicked
|
||||
self.plot_item.addItem(marker_clicked)
|
||||
|
||||
self.marker_clicked_1d.append(marker_clicked_list)
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
if self.marker_2d is not None:
|
||||
continue
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
|
||||
def snap_to_data(self, x, y) -> tuple:
|
||||
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
|
||||
"""
|
||||
Finds the nearest data points to the given x and y coordinates.
|
||||
|
||||
Args:
|
||||
x: The x-coordinate
|
||||
y: The y-coordinate
|
||||
x: The x-coordinate of the mouse cursor
|
||||
y: The y-coordinate of the mouse cursor
|
||||
|
||||
Returns:
|
||||
tuple: The nearest x and y values
|
||||
tuple: x and y values snapped to the nearest data
|
||||
"""
|
||||
y_values_1d = []
|
||||
x_values_1d = []
|
||||
y_values = defaultdict(list)
|
||||
x_values = defaultdict(list)
|
||||
image_2d = None
|
||||
|
||||
# Iterate through items in the plot
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem): # 1D plot
|
||||
x_data, y_data = item.xData, item.yData
|
||||
name = item.name()
|
||||
plot_data = item._getDisplayDataset()
|
||||
if plot_data is None:
|
||||
continue
|
||||
x_data, y_data = plot_data.x, plot_data.y
|
||||
if x_data is not None and y_data is not None:
|
||||
if self.is_log_x:
|
||||
min_x_data = np.min(x_data[x_data > 0])
|
||||
@@ -112,25 +120,25 @@ class Crosshair(QObject):
|
||||
min_x_data = np.min(x_data)
|
||||
max_x_data = np.max(x_data)
|
||||
if x < min_x_data or x > max_x_data:
|
||||
return None, None
|
||||
y_values[name] = None
|
||||
x_values[name] = None
|
||||
continue
|
||||
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
|
||||
y_values_1d.append(closest_y)
|
||||
x_values_1d.append(closest_x)
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor
|
||||
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))
|
||||
|
||||
# Handle 1D plot
|
||||
if y_values_1d:
|
||||
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
|
||||
if x_values and y_values:
|
||||
if all(v is None for v in x_values.values()) or all(
|
||||
v is None for v in y_values.values()
|
||||
):
|
||||
return None, None
|
||||
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
|
||||
return closest_x, y_values_1d
|
||||
|
||||
# Handle 2D plot
|
||||
if image_2d is not None:
|
||||
x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
return x_idx, y_idx
|
||||
return x_values, y_values
|
||||
|
||||
return None, None
|
||||
|
||||
@@ -156,8 +164,9 @@ class Crosshair(QObject):
|
||||
Args:
|
||||
event: The mouse moved event
|
||||
"""
|
||||
self.check_log()
|
||||
pos = event[0]
|
||||
self.update_markers()
|
||||
self.positionChanged.emit((pos.x(), pos.y()))
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
self.v_line.setPos(mouse_point.x())
|
||||
@@ -168,27 +177,34 @@ class Crosshair(QObject):
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
x, y_values = self.snap_to_data(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
|
||||
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
coordinate_to_emit = (
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
name = item.name()
|
||||
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])
|
||||
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
for i, y_val in enumerate(y_values):
|
||||
self.marker_moved_1d[i].setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
coordinate_to_emit = (x, y_values)
|
||||
name = item.config.monitor
|
||||
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:
|
||||
continue
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -196,40 +212,69 @@ class Crosshair(QObject):
|
||||
Args:
|
||||
event: The mouse clicked event
|
||||
"""
|
||||
self.check_log()
|
||||
|
||||
# we only accept left mouse clicks
|
||||
if event.button() != Qt.MouseButton.LeftButton:
|
||||
return
|
||||
self.update_markers()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
self.positionClicked.emit((x, y))
|
||||
|
||||
if self.is_log_x:
|
||||
x = 10**x
|
||||
if self.is_log_y:
|
||||
y = 10**y
|
||||
x, y_values = self.snap_to_data(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
|
||||
|
||||
for item in self.plot_item.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
coordinate_to_emit = (
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
name = item.name()
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_clicked_1d[name].setData([x], [y])
|
||||
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
for i, y_val in enumerate(y_values):
|
||||
for marker in self.marker_clicked_1d[i]:
|
||||
marker.setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
coordinate_to_emit = (x, y_values)
|
||||
name = item.config.monitor
|
||||
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.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
self.marker_2d.setPos([x, y_values])
|
||||
else:
|
||||
continue
|
||||
|
||||
def clear_markers(self):
|
||||
"""Clears the markers from the plot."""
|
||||
for marker in self.marker_moved_1d.values():
|
||||
marker.clear()
|
||||
for marker in self.marker_clicked_1d.values():
|
||||
marker.clear()
|
||||
|
||||
def check_log(self):
|
||||
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
|
||||
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
|
||||
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
|
||||
self.clear_markers()
|
||||
|
||||
def check_derivatives(self):
|
||||
"""Checks if the derivatives are enabled and updates the internal state accordingly."""
|
||||
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
self.v_line.deleteLater()
|
||||
self.h_line.deleteLater()
|
||||
self.clear_markers()
|
||||
|
||||
@@ -19,7 +19,7 @@ class EntryValidator:
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
if entry is None:
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
149
bec_widgets/utils/generate_designer_plugin.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
|
||||
|
||||
|
||||
class DesignerPluginInfo:
|
||||
def __init__(self, plugin_class):
|
||||
self.plugin_class = plugin_class
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
)
|
||||
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
|
||||
|
||||
# first sentence / line of the docstring is used as tooltip
|
||||
self.plugin_tooltip = (
|
||||
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
|
||||
if plugin_class.__doc__
|
||||
else self.plugin_name_pascal
|
||||
)
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
self._excluded = False
|
||||
self.widget = widget
|
||||
self.info = DesignerPluginInfo(widget)
|
||||
if widget.__name__ in EXCLUDED_PLUGINS:
|
||||
|
||||
self._excluded = True
|
||||
return
|
||||
|
||||
self.templates = {}
|
||||
self.template_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
|
||||
)
|
||||
|
||||
def run(self, validate=True):
|
||||
if self._excluded:
|
||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||
return
|
||||
if validate:
|
||||
self._check_class_validity()
|
||||
self._load_templates()
|
||||
self._write_templates()
|
||||
|
||||
def _check_class_validity(self):
|
||||
|
||||
# Check if the widget is a QWidget subclass
|
||||
if not issubclass(self.widget, QObject):
|
||||
return
|
||||
|
||||
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
|
||||
signature = list(inspect.signature(self.widget.__init__).parameters.values())
|
||||
if len(signature) == 1 or signature[1].name != "parent":
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must have parent as the first argument."
|
||||
)
|
||||
|
||||
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
|
||||
if not base_cls:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
|
||||
)
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
)
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must call the super constructor with parent."
|
||||
)
|
||||
|
||||
def _write_templates(self):
|
||||
self._write_register()
|
||||
self._write_plugin()
|
||||
self._write_pyproject()
|
||||
|
||||
def _write_register(self):
|
||||
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["register"].format(**self.info.__dict__))
|
||||
|
||||
def _write_plugin(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["plugin"].format(**self.info.__dict__))
|
||||
|
||||
def _write_pyproject(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
|
||||
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(str(out))
|
||||
|
||||
def _load_templates(self):
|
||||
for file in os.listdir(self.template_path):
|
||||
if not file.endswith(".template"):
|
||||
continue
|
||||
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
|
||||
self.templates[file.split(".")[0]] = f.read()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
|
||||
|
||||
generator = DesignerPluginGenerator(SpinnerWidget)
|
||||
generator.run(validate=False)
|
||||
54
bec_widgets/utils/plugin_templates/plugin.template
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
{widget_import}
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon({plugin_name_pascal}.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "{plugin_name_snake}"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "{plugin_name_pascal}"
|
||||
|
||||
def toolTip(self):
|
||||
return "{plugin_tooltip}"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/utils/plugin_templates/register.template
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
{plugin_import}
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,8 +1,13 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
@@ -38,3 +43,116 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
|
||||
def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_top_level: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection = []
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
|
||||
Args:
|
||||
class_info(BECClassInfo): The class information
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_top_level]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
|
||||
|
||||
|
||||
def get_rpc_classes(repo_name: str) -> BECClassContainer:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
collection = BECClassContainer()
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
for file in files:
|
||||
if not file.endswith(".py") or file.startswith("__"):
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||
if len(subs) == 1 and not subs[0]:
|
||||
module_name = file.split(".")[0]
|
||||
else:
|
||||
module_name = ".".join(subs + [file.split(".")[0]])
|
||||
|
||||
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
class_info.is_top_level = True
|
||||
collection.add_class(class_info)
|
||||
|
||||
return collection
|
||||
|
||||
92
bec_widgets/utils/reference_utils.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
from qtpy.QtGui import QPixmap
|
||||
|
||||
import bec_widgets
|
||||
|
||||
REFERENCE_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/references"
|
||||
)
|
||||
REFERENCE_DIR_FAILURES = os.path.join(
|
||||
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/reference_failures"
|
||||
)
|
||||
|
||||
|
||||
def compare_images(image1_path: str, reference_image_path: str):
|
||||
"""
|
||||
Load two images and compare them pixel by pixel
|
||||
|
||||
Args:
|
||||
image1_path(str): The path to the first image
|
||||
reference_image_path(str): The path to the reference image
|
||||
|
||||
Raises:
|
||||
ValueError: If the images are different
|
||||
"""
|
||||
image1 = Image.open(image1_path)
|
||||
image2 = Image.open(reference_image_path)
|
||||
if image1.size != image2.size:
|
||||
raise ValueError("Image size has changed")
|
||||
diff = ImageChops.difference(image1, image2)
|
||||
if diff.getbbox():
|
||||
# copy image1 to the reference directory to upload as artifact
|
||||
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
|
||||
image_name = os.path.join(REFERENCE_DIR_FAILURES, os.path.basename(image1_path))
|
||||
image1.save(image_name)
|
||||
print(f"Image saved to {image_name}")
|
||||
|
||||
raise ValueError("Images are different")
|
||||
|
||||
|
||||
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
|
||||
"""
|
||||
Save a rendering of a widget and compare it to a reference image
|
||||
|
||||
Args:
|
||||
widget(any): The widget to render
|
||||
output_directory(str): The directory to save the image to
|
||||
suffix(str): A suffix to append to the image name
|
||||
|
||||
Raises:
|
||||
ValueError: If the images are different
|
||||
|
||||
Examples:
|
||||
snap_and_compare(widget, tmpdir, suffix="started")
|
||||
|
||||
"""
|
||||
|
||||
if not isinstance(output_directory, str):
|
||||
output_directory = str(output_directory)
|
||||
|
||||
os_suffix = sys.platform
|
||||
|
||||
name = (
|
||||
f"{widget.__class__.__name__}_{suffix}_{os_suffix}.png"
|
||||
if suffix
|
||||
else f"{widget.__class__.__name__}_{os_suffix}.png"
|
||||
)
|
||||
|
||||
# Save the widget to a pixmap
|
||||
test_image_path = os.path.join(output_directory, name)
|
||||
pixmap = QPixmap(widget.size())
|
||||
widget.render(pixmap)
|
||||
pixmap.save(test_image_path)
|
||||
|
||||
try:
|
||||
reference_path = os.path.join(REFERENCE_DIR, f"{widget.__class__.__name__}")
|
||||
reference_image_path = os.path.join(reference_path, name)
|
||||
|
||||
if not os.path.exists(reference_image_path):
|
||||
raise ValueError(f"Reference image not found: {reference_image_path}")
|
||||
|
||||
compare_images(test_image_path, reference_image_path)
|
||||
|
||||
except ValueError:
|
||||
image = Image.open(test_image_path)
|
||||
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
|
||||
image_name = os.path.join(REFERENCE_DIR_FAILURES, name)
|
||||
image.save(image_name)
|
||||
print(f"Image saved to {image_name}")
|
||||
raise
|
||||
@@ -1,27 +1,45 @@
|
||||
from qtpy import QT_VERSION
|
||||
import os
|
||||
|
||||
from qtpy import PYQT6, PYSIDE6, QT_VERSION
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict = None):
|
||||
super().__init__(baseinstance)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
self.baseinstance = baseinstance
|
||||
|
||||
def createWidget(self, class_name, parent=None, name=""):
|
||||
if class_name in self.custom_widgets:
|
||||
widget = self.custom_widgets[class_name](parent)
|
||||
widget.setObjectName(name)
|
||||
return widget
|
||||
return super().createWidget(class_name, parent, name)
|
||||
|
||||
|
||||
class UILoader:
|
||||
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
|
||||
"""Universal UI loader for PyQt6 and PySide6."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
if QT_VERSION.startswith("5"):
|
||||
# PyQt5 or PySide2
|
||||
from qtpy import uic
|
||||
|
||||
self.loader = uic.loadUi
|
||||
elif QT_VERSION.startswith("6"):
|
||||
# PyQt6 or PySide6
|
||||
try:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
widgets = get_rpc_classes("bec_widgets").top_level_classes
|
||||
|
||||
self.loader = self.load_ui_pyside6
|
||||
except ImportError:
|
||||
from PyQt6.uic import loadUi
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
|
||||
self.loader = loadUi
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
elif PYQT6:
|
||||
self.loader = self.load_ui_pyqt6
|
||||
else:
|
||||
raise ImportError("No compatible Qt bindings found.")
|
||||
|
||||
def load_ui_pyside6(self, ui_file, parent=None):
|
||||
"""
|
||||
@@ -33,9 +51,8 @@ class UILoader:
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
loader = QUiLoader(parent)
|
||||
loader = CustomUiLoader(parent, self.custom_widgets)
|
||||
file = QFile(ui_file)
|
||||
if not file.open(QIODevice.ReadOnly):
|
||||
raise IOError(f"Cannot open file: {ui_file}")
|
||||
@@ -43,6 +60,71 @@ class UILoader:
|
||||
file.close()
|
||||
return widget
|
||||
|
||||
def load_ui_pyqt6(self, ui_file, parent=None):
|
||||
"""
|
||||
Specific loader for PyQt6 using loadUi.
|
||||
Args:
|
||||
ui_file(str): Path to the .ui file.
|
||||
parent(QWidget): Parent widget.
|
||||
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
from PyQt6.uic.Loader.loader import DynamicUILoader
|
||||
|
||||
class CustomDynamicUILoader(DynamicUILoader):
|
||||
def __init__(self, package, custom_widgets: dict = None):
|
||||
super().__init__(package)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
def _handle_custom_widgets(self, el):
|
||||
"""Handle the <customwidgets> element."""
|
||||
|
||||
def header2module(header):
|
||||
"""header2module(header) -> string
|
||||
|
||||
Convert paths to C++ header files to according Python modules
|
||||
>>> header2module("foo/bar/baz.h")
|
||||
'foo.bar.baz'
|
||||
"""
|
||||
|
||||
if header.endswith(".h"):
|
||||
header = header[:-2]
|
||||
|
||||
mpath = []
|
||||
for part in header.split("/"):
|
||||
# Ignore any empty parts or those that refer to the current
|
||||
# directory.
|
||||
if part not in ("", "."):
|
||||
if part == "..":
|
||||
# We should allow this for Python3.
|
||||
raise SyntaxError(
|
||||
"custom widget header file name may not contain '..'."
|
||||
)
|
||||
|
||||
mpath.append(part)
|
||||
|
||||
return ".".join(mpath)
|
||||
|
||||
for custom_widget in el:
|
||||
classname = custom_widget.findtext("class")
|
||||
header = custom_widget.findtext("header")
|
||||
if header:
|
||||
header = self._translate_bec_widgets_header(header)
|
||||
self.factory.addCustomWidget(
|
||||
classname,
|
||||
custom_widget.findtext("extends") or "QWidget",
|
||||
header2module(header),
|
||||
)
|
||||
|
||||
def _translate_bec_widgets_header(self, header):
|
||||
for name, value in self.custom_widgets.items():
|
||||
if header == DesignerPluginInfo.pascal_to_snake(name):
|
||||
return value.__module__
|
||||
return header
|
||||
|
||||
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
|
||||
|
||||
def load_ui(self, ui_file, parent=None):
|
||||
"""
|
||||
Universal UI loader method.
|
||||
|
||||
@@ -44,8 +44,11 @@ class ComboBoxHandler(WidgetHandler):
|
||||
def get_value(self, widget: QComboBox) -> int:
|
||||
return widget.currentIndex()
|
||||
|
||||
def set_value(self, widget: QComboBox, value: int) -> None:
|
||||
widget.setCurrentIndex(value)
|
||||
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
||||
if isinstance(value, str):
|
||||
value = widget.findText(value)
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
@@ -119,7 +122,7 @@ class WidgetIO:
|
||||
widget: Widget instance.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = WidgetIO._handlers.get(type(widget))
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().get_value(widget) # Instantiate the handler
|
||||
if not ignore_errors:
|
||||
@@ -136,12 +139,48 @@ class WidgetIO:
|
||||
value: Value to set.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = WidgetIO._handlers.get(type(widget))
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
handler_class().set_value(widget, value) # Instantiate the handler
|
||||
elif not ignore_errors:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
|
||||
@staticmethod
|
||||
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
||||
"""
|
||||
Check if the new limits are within the current limits, if not adjust the limits.
|
||||
|
||||
Args:
|
||||
number(float): The new value to check against the limits.
|
||||
"""
|
||||
|
||||
min_value = spin_box.minimum()
|
||||
max_value = spin_box.maximum()
|
||||
|
||||
# Calculate the new limits
|
||||
new_limit = number + 5 * number
|
||||
|
||||
if number < min_value:
|
||||
spin_box.setMinimum(new_limit)
|
||||
elif number > max_value:
|
||||
spin_box.setMaximum(new_limit)
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
Find the appropriate handler for the widget by checking its base classes.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
for base in type(widget).__mro__:
|
||||
if base in WidgetIO._handlers:
|
||||
return WidgetIO._handlers[base]
|
||||
return None
|
||||
|
||||
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import yaml
|
||||
from qtpy.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
def load_yaml(instance) -> Union[dict, None]:
|
||||
def load_yaml_gui(instance) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
@@ -20,12 +20,25 @@ def load_yaml(instance) -> Union[dict, None]:
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
config = load_yaml(file_path)
|
||||
return config
|
||||
|
||||
|
||||
def load_yaml(file_path: str) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
Args:
|
||||
file_path(str): Path to the YAML file.
|
||||
|
||||
Returns:
|
||||
dict: Configuration data loaded from the YAML file.
|
||||
"""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
config = yaml.safe_load(file)
|
||||
config = yaml.load(file, Loader=yaml.FullLoader)
|
||||
return config
|
||||
|
||||
except FileNotFoundError:
|
||||
@@ -38,7 +51,7 @@ def load_yaml(instance) -> Union[dict, None]:
|
||||
print(f"An error occurred while loading the settings from {file_path}: {e}")
|
||||
|
||||
|
||||
def save_yaml(instance, config: dict) -> None:
|
||||
def save_yaml_gui(instance, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
@@ -51,6 +64,17 @@ def save_yaml(instance, config: dict) -> None:
|
||||
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
save_yaml(file_path, config)
|
||||
|
||||
|
||||
def save_yaml(file_path: str, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
Args:
|
||||
file_path(str): Path to the YAML file.
|
||||
config(dict): Configuration data to be saved.
|
||||
"""
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
from .dock import BECDock, BECDockArea
|
||||
from .figure import BECFigure, FigureConfig
|
||||
from .scan_control import ScanControl
|
||||
from .spiral_progress_bar import SpiralProgressBar
|
||||
|
||||
|
||||
123
bec_widgets/widgets/base_classes/device_input_base.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
class DeviceInputConfig(ConnectionConfig):
|
||||
device_filter: str | list[str] | None = None
|
||||
default: str | None = None
|
||||
arg_name: str | None = None
|
||||
|
||||
|
||||
class DeviceInputBase(BECWidget):
|
||||
"""
|
||||
Mixin class for device input widgets. This class provides methods to get the device list and device object based
|
||||
on the current text of the widget.
|
||||
"""
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id=None):
|
||||
if config is None:
|
||||
config = DeviceInputConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DeviceInputConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self._device_filter = None
|
||||
self._devices = []
|
||||
|
||||
@property
|
||||
def devices(self) -> list[str]:
|
||||
"""
|
||||
Get the list of devices.
|
||||
|
||||
Returns:
|
||||
list[str]: List of devices.
|
||||
"""
|
||||
return self._devices
|
||||
|
||||
@devices.setter
|
||||
def devices(self, value: list[str]):
|
||||
"""
|
||||
Set the list of devices.
|
||||
|
||||
Args:
|
||||
value: List of devices.
|
||||
"""
|
||||
self._devices = value
|
||||
|
||||
def set_device_filter(self, device_filter: str | list[str]):
|
||||
"""
|
||||
Set the device filter.
|
||||
|
||||
Args:
|
||||
device_filter(str): Device filter, name of the device class.
|
||||
"""
|
||||
self.validate_device_filter(device_filter)
|
||||
self.config.device_filter = device_filter
|
||||
self._device_filter = device_filter
|
||||
|
||||
def set_default_device(self, default_device: str):
|
||||
"""
|
||||
Set the default device.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
self.validate_device(default_device)
|
||||
self.config.default = default_device
|
||||
|
||||
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
|
||||
"""
|
||||
Get the list of device names based on the filter of current BEC client.
|
||||
|
||||
Args:
|
||||
filter(str|None): Class name filter to apply on the device list.
|
||||
|
||||
Returns:
|
||||
devices(list[str]): List of device names.
|
||||
"""
|
||||
all_devices = self.dev.enabled_devices
|
||||
if filter is not None:
|
||||
self.validate_device_filter(filter)
|
||||
if isinstance(filter, str):
|
||||
filter = [filter]
|
||||
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
|
||||
else:
|
||||
devices = [device.name for device in all_devices]
|
||||
return devices
|
||||
|
||||
def get_available_filters(self):
|
||||
"""
|
||||
Get the available device classes which can be used as filters.
|
||||
"""
|
||||
all_devices = self.dev.enabled_devices
|
||||
filters = {device.__class__.__name__ for device in all_devices}
|
||||
return filters
|
||||
|
||||
def validate_device_filter(self, filter: str | list[str]) -> None:
|
||||
"""
|
||||
Validate the device filter if the class name is present in the current BEC instance.
|
||||
|
||||
Args:
|
||||
filter(str|list[str]): Class name to use as a device filter.
|
||||
"""
|
||||
if isinstance(filter, str):
|
||||
filter = [filter]
|
||||
available_filters = self.get_available_filters()
|
||||
for f in filter:
|
||||
if f not in available_filters:
|
||||
raise ValueError(f"Device filter {f} is not valid.")
|
||||
|
||||
def validate_device(self, device: str) -> None:
|
||||
"""
|
||||
Validate the device if it is present in current BEC instance.
|
||||
|
||||
Args:
|
||||
device(str): Device to validate.
|
||||
"""
|
||||
if device not in self.get_device_list(self.config.device_filter):
|
||||
raise ValueError(f"Device {device} is not valid.")
|
||||
122
bec_widgets/widgets/bec_queue/bec_queue.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import Qt, Slot
|
||||
from qtpy.QtWidgets import QHBoxLayout, QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
class BECQueue(BECWidget, QWidget):
|
||||
"""
|
||||
Widget to display the BEC queue.
|
||||
"""
|
||||
|
||||
ICON_NAME = "edit_note"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
):
|
||||
super().__init__(client, config, gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.table = QTableWidget(self)
|
||||
self.layout = QHBoxLayout(self)
|
||||
self.layout.addWidget(self.table)
|
||||
|
||||
self.table.setColumnCount(3)
|
||||
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
|
||||
self.reset_content()
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_queue(self, content, _metadata):
|
||||
"""
|
||||
Update the queue table with the latest queue information.
|
||||
|
||||
Args:
|
||||
content (dict): The queue content.
|
||||
_metadata (dict): The metadata.
|
||||
"""
|
||||
# only show the primary queue for now
|
||||
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
|
||||
self.table.setRowCount(len(queue_info))
|
||||
self.table.clearContents()
|
||||
|
||||
if not queue_info:
|
||||
self.reset_content()
|
||||
return
|
||||
|
||||
for index, item in enumerate(queue_info):
|
||||
blocks = item.get("request_blocks", [])
|
||||
scan_types = []
|
||||
scan_numbers = []
|
||||
status = item.get("status", "")
|
||||
for request_block in blocks:
|
||||
scan_type = request_block.get("content", {}).get("scan_type", "")
|
||||
if scan_type:
|
||||
scan_types.append(scan_type)
|
||||
scan_number = request_block.get("scan_number", "")
|
||||
if scan_number:
|
||||
scan_numbers.append(str(scan_number))
|
||||
if scan_types:
|
||||
scan_types = ", ".join(scan_types)
|
||||
if scan_numbers:
|
||||
scan_numbers = ", ".join(scan_numbers)
|
||||
self.set_row(index, scan_numbers, scan_types, status)
|
||||
|
||||
def format_item(self, content: str) -> QTableWidgetItem:
|
||||
"""
|
||||
Format the content of the table item.
|
||||
|
||||
Args:
|
||||
content (str): The content to be formatted.
|
||||
|
||||
Returns:
|
||||
QTableWidgetItem: The formatted item.
|
||||
"""
|
||||
if not content or not isinstance(content, str):
|
||||
content = ""
|
||||
item = QTableWidgetItem(content)
|
||||
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
return item
|
||||
|
||||
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
|
||||
"""
|
||||
Set the row of the table.
|
||||
|
||||
Args:
|
||||
index (int): The index of the row.
|
||||
scan_number (str): The scan number.
|
||||
scan_type (str): The scan type.
|
||||
status (str): The status.
|
||||
"""
|
||||
|
||||
self.table.setItem(index, 0, self.format_item(scan_number))
|
||||
self.table.setItem(index, 1, self.format_item(scan_type))
|
||||
self.table.setItem(index, 2, self.format_item(status))
|
||||
|
||||
def reset_content(self):
|
||||
"""
|
||||
Reset the content of the table.
|
||||
"""
|
||||
|
||||
self.table.setRowCount(1)
|
||||
self.set_row(0, "", "", "")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECQueue()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
1
bec_widgets/widgets/bec_queue/bec_queue.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['bec_queue.py']}
|
||||
58
bec_widgets/widgets/bec_queue/bec_queue_plugin.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECQueue' name='bec_queue'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECQueue(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Services"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECQueue.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_queue"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECQueue"
|
||||
|
||||
def toolTip(self):
|
||||
return "Widget to display the BEC queue."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/bec_queue/register_bec_queue.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.bec_queue.bec_queue_plugin import BECQueuePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECQueuePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
0
bec_widgets/widgets/bec_status_box/__init__.py
Normal file
319
bec_widgets/widgets/bec_status_box/bec_status_box.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""This module contains the BECStatusBox widget, which displays the status of different BEC services in a collapsible tree widget.
|
||||
The widget automatically updates the status of all running BEC services, and displays their status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from qtpy.QtCore import QObject, QTimer, Signal, Slot
|
||||
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
# TODO : Put normal imports back when Pydantic gets faster
|
||||
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
|
||||
|
||||
|
||||
@dataclass
|
||||
class BECServiceInfoContainer:
|
||||
"""Container to store information about the BEC services."""
|
||||
|
||||
service_name: str
|
||||
status: str
|
||||
info: dict
|
||||
metrics: dict | None
|
||||
|
||||
|
||||
class BECServiceStatusMixin(QObject):
|
||||
"""Mixin to receive the latest service status from the BEC server and emit it via services_update signal.
|
||||
|
||||
Args:
|
||||
client (BECClient): The client object to connect to the BEC server.
|
||||
"""
|
||||
|
||||
services_update = Signal(dict, dict)
|
||||
|
||||
ICON_NAME = "dns"
|
||||
|
||||
def __init__(self, parent, client: BECClient):
|
||||
super().__init__(parent)
|
||||
self.client = client
|
||||
self._service_update_timer = QTimer()
|
||||
self._service_update_timer.timeout.connect(self._get_service_status)
|
||||
self._service_update_timer.start(1000)
|
||||
|
||||
def _get_service_status(self):
|
||||
"""Get the latest service status from the BEC server."""
|
||||
# pylint: disable=protected-access
|
||||
self.client._update_existing_services()
|
||||
self.services_update.emit(self.client._services_info, self.client._services_metric)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the BECServiceStatusMixin."""
|
||||
self._service_update_timer.stop()
|
||||
self._service_update_timer.deleteLater()
|
||||
|
||||
|
||||
class BECStatusBox(BECWidget, QWidget):
|
||||
"""An autonomous widget to display the status of BEC services.
|
||||
|
||||
Args:
|
||||
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
|
||||
box_name Optional(str): The name of the top service label. Defaults to "BEC Server".
|
||||
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
|
||||
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
|
||||
gui_id Optional(str): The unique id for the widget. Defaults to None.
|
||||
"""
|
||||
|
||||
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
||||
|
||||
service_update = Signal(BECServiceInfoContainer)
|
||||
bec_core_state = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
box_name: str = "BEC Server",
|
||||
client: BECClient = None,
|
||||
bec_service_status_mixin: BECServiceStatusMixin = None,
|
||||
gui_id: str = None,
|
||||
):
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.tree = QTreeWidget(self)
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
self.box_name = box_name
|
||||
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
|
||||
|
||||
if not bec_service_status_mixin:
|
||||
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
|
||||
self.bec_service_status = bec_service_status_mixin
|
||||
|
||||
self.init_ui()
|
||||
self.bec_service_status.services_update.connect(self.update_service_status)
|
||||
self.bec_core_state.connect(self.update_top_item_status)
|
||||
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Init the UI for the BECStatusBox widget, should only take place once."""
|
||||
self.init_ui_tree_widget()
|
||||
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
|
||||
tree_item = QTreeWidgetItem()
|
||||
tree_item.setExpanded(True)
|
||||
tree_item.setDisabled(True)
|
||||
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
|
||||
self.tree.addTopLevelItem(tree_item)
|
||||
self.tree.setItemWidget(tree_item, 0, top_label)
|
||||
self.service_update.connect(top_label.update_config)
|
||||
self._initialized = True
|
||||
|
||||
def init_ui_tree_widget(self) -> None:
|
||||
"""Initialise the tree widget for the status box."""
|
||||
self.tree.setHeaderHidden(True)
|
||||
# TODO probably here is a problem still with setting the stylesheet
|
||||
self.tree.setStyleSheet(
|
||||
"QTreeWidget::item:!selected "
|
||||
"{ "
|
||||
"border: 1px solid gainsboro; "
|
||||
"border-left: none; "
|
||||
"border-top: none; "
|
||||
"}"
|
||||
"QTreeWidget::item:selected {}"
|
||||
)
|
||||
|
||||
def _create_status_widget(
|
||||
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
|
||||
) -> StatusItem:
|
||||
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
|
||||
information about the service in the status_container.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info Optional(dict): The information about the service. Default is {}
|
||||
metric Optional(dict): Metrics for the respective service. Default is None
|
||||
|
||||
Returns:
|
||||
StatusItem: The status item widget.
|
||||
"""
|
||||
if info is None:
|
||||
info = {}
|
||||
self._update_status_container(service_name, status, info, metrics)
|
||||
item = StatusItem(parent=self, config=self.status_container[service_name]["info"])
|
||||
return item
|
||||
|
||||
@Slot(str)
|
||||
def update_top_item_status(self, status: BECStatus) -> None:
|
||||
"""Method to update the status of the top item in the tree widget.
|
||||
Gets the status from the Signal 'bec_core_state' and updates the StatusItem via the signal 'service_update'.
|
||||
|
||||
Args:
|
||||
status (BECStatus): The state of the core services.
|
||||
"""
|
||||
self.status_container[self.box_name]["info"].status = status
|
||||
self.service_update.emit(self.status_container[self.box_name]["info"])
|
||||
|
||||
def _update_status_container(
|
||||
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
|
||||
) -> None:
|
||||
"""Update the status_container with the newest status and metrics for the BEC service.
|
||||
If information about the service already exists, it will create a new entry.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info (dict): The information about the service.
|
||||
metrics (dict): The metrics of the service.
|
||||
"""
|
||||
container = self.status_container[service_name].get("info", None)
|
||||
|
||||
if container:
|
||||
container.status = status.name
|
||||
container.info = info
|
||||
container.metrics = metrics
|
||||
return
|
||||
service_info_item = BECServiceInfoContainer(
|
||||
service_name=service_name,
|
||||
status=status.name if isinstance(status, BECStatus) else status,
|
||||
info=info,
|
||||
metrics=metrics,
|
||||
)
|
||||
self.status_container[service_name].update({"info": service_info_item})
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
|
||||
"""Callback function services_metric from BECServiceStatusMixin.
|
||||
It updates the status of all services.
|
||||
|
||||
Args:
|
||||
services_info (dict): A dictionary containing the service status for all running BEC services.
|
||||
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
|
||||
"""
|
||||
checked = [self.box_name]
|
||||
services_info = self.update_core_services(services_info, services_metric)
|
||||
checked.extend(self.CORE_SERVICES)
|
||||
|
||||
for service_name, msg in sorted(services_info.items()):
|
||||
checked.append(service_name)
|
||||
metric_msg = services_metric.get(service_name, None)
|
||||
metrics = metric_msg.metrics if metric_msg else None
|
||||
if service_name in self.status_container:
|
||||
if not msg:
|
||||
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
|
||||
continue
|
||||
self._update_status_container(service_name, msg.status, msg.info, metrics)
|
||||
self.service_update.emit(self.status_container[service_name]["info"])
|
||||
continue
|
||||
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
self.check_redundant_tree_items(checked)
|
||||
|
||||
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
|
||||
"""Update the core services of BEC, and emit the updated status to the BECStatusBox.
|
||||
|
||||
Args:
|
||||
services_info (dict): A dictionary containing the service status of different services.
|
||||
services_metric (dict): A dictionary containing the service metrics of different services.
|
||||
|
||||
Returns:
|
||||
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
|
||||
"""
|
||||
core_state = BECStatus.RUNNING
|
||||
for service_name in sorted(self.CORE_SERVICES):
|
||||
metric_msg = services_metric.get(service_name, None)
|
||||
metrics = metric_msg.metrics if metric_msg else None
|
||||
msg = services_info.pop(service_name, None)
|
||||
if service_name not in self.status_container:
|
||||
if not msg:
|
||||
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
|
||||
continue
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
continue
|
||||
if not msg:
|
||||
self.status_container[service_name]["info"].status = "NOTCONNECTED"
|
||||
core_state = None
|
||||
else:
|
||||
self._update_status_container(service_name, msg.status, msg.info, metrics)
|
||||
if core_state:
|
||||
core_state = msg.status if msg.status.value < core_state.value else core_state
|
||||
|
||||
self.service_update.emit(self.status_container[service_name]["info"])
|
||||
|
||||
self.bec_core_state.emit(core_state.name if core_state else "NOTCONNECTED")
|
||||
return services_info
|
||||
|
||||
def check_redundant_tree_items(self, checked: list) -> None:
|
||||
"""Utility method to check and remove redundant objects from the BECStatusBox.
|
||||
|
||||
Args:
|
||||
checked (list): A list of services that are currently running.
|
||||
"""
|
||||
to_be_deleted = [key for key in self.status_container if key not in checked]
|
||||
|
||||
for key in to_be_deleted:
|
||||
obj = self.status_container.pop(key)
|
||||
item = obj["item"]
|
||||
self.status_container[self.box_name]["item"].removeChild(item)
|
||||
|
||||
def add_tree_item(
|
||||
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
|
||||
) -> None:
|
||||
"""Method to add a new QTreeWidgetItem together with a StatusItem to the tree widget.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info (dict): The information about the service.
|
||||
metrics (dict): The metrics of the service.
|
||||
"""
|
||||
item_widget = self._create_status_widget(service_name, status, info, metrics)
|
||||
item = QTreeWidgetItem()
|
||||
self.service_update.connect(item_widget.update_config)
|
||||
self.status_container[self.box_name]["item"].addChild(item)
|
||||
self.tree.setItemWidget(item, 0, item_widget)
|
||||
self.status_container[service_name].update({"item": item, "widget": item_widget})
|
||||
|
||||
@Slot(QTreeWidgetItem, int)
|
||||
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
|
||||
"""Callback function for double clicks on individual QTreeWidgetItems in the collapsed section.
|
||||
|
||||
Args:
|
||||
item (QTreeWidgetItem): The item that was double clicked.
|
||||
column (int): The column that was double clicked.
|
||||
"""
|
||||
for _, objects in self.status_container.items():
|
||||
if objects["item"] == item:
|
||||
objects["widget"].show_popup()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the BECStatusBox widget."""
|
||||
self.bec_service_status.cleanup()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main method to run the BECStatusBox widget."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
main_window = BECStatusBox()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['bec_status_box.py']}
|
||||
58
bec_widgets/widgets/bec_status_box/bec_status_box_plugin.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECStatusBox' name='bec_status_box'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECStatusBox(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Services"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECStatusBox.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_status_box"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECStatusBox"
|
||||
|
||||
def toolTip(self):
|
||||
return "An autonomous widget to display the status of BEC services."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box_plugin import BECStatusBoxPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
144
bec_widgets/widgets/bec_status_box/status_item.py
Normal file
@@ -0,0 +1,144 @@
|
||||
""" Module for a StatusItem widget to display status and metrics for a BEC service.
|
||||
The widget is bound to be used with the BECStatusBox widget."""
|
||||
|
||||
import enum
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from qtpy.QtCore import Qt, Slot
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
import bec_widgets
|
||||
|
||||
# TODO : Put normal imports back when Pydantic gets faster
|
||||
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class IconsEnum(enum.Enum):
|
||||
"""Enum class for icons in the status item widget."""
|
||||
|
||||
RUNNING = os.path.join(MODULE_PATH, "assets", "status_icons", "running.svg")
|
||||
BUSY = os.path.join(MODULE_PATH, "assets", "status_icons", "refresh.svg")
|
||||
IDLE = os.path.join(MODULE_PATH, "assets", "status_icons", "warning.svg")
|
||||
ERROR = os.path.join(MODULE_PATH, "assets", "status_icons", "error.svg")
|
||||
NOTCONNECTED = os.path.join(MODULE_PATH, "assets", "status_icons", "not_connected.svg")
|
||||
|
||||
|
||||
class StatusItem(QWidget):
|
||||
"""A widget to display the status of a service.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
config (dict): The configuration for the service, must be a BECServiceInfoContainer.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget = None, config=None):
|
||||
QWidget.__init__(self, parent=parent)
|
||||
if config is None:
|
||||
# needed because we need parent to be the first argument for QT Designer
|
||||
raise ValueError(
|
||||
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
|
||||
)
|
||||
self.config = config
|
||||
self.parent = parent
|
||||
self.layout = None
|
||||
self._label = None
|
||||
self._icon = None
|
||||
self.icon_size = (24, 24)
|
||||
|
||||
self._popup_label_ref = {}
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Init the UI for the status item widget."""
|
||||
self.layout = QHBoxLayout()
|
||||
self.layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.setLayout(self.layout)
|
||||
self._label = QLabel()
|
||||
self._icon = QLabel()
|
||||
self.layout.addWidget(self._label)
|
||||
self.layout.addWidget(self._icon)
|
||||
self.update_ui()
|
||||
|
||||
@Slot(dict)
|
||||
def update_config(self, config) -> None:
|
||||
"""Update the config of the status item widget.
|
||||
|
||||
Args:
|
||||
config (dict): Config updates from parent widget, must be a BECServiceInfoContainer.
|
||||
"""
|
||||
if self.config is None or config.service_name != self.config.service_name:
|
||||
return
|
||||
self.config = config
|
||||
self.update_ui()
|
||||
|
||||
def update_ui(self) -> None:
|
||||
"""Update the UI of the labels, and popup dialog."""
|
||||
if self.config is None:
|
||||
return
|
||||
self.set_text()
|
||||
self.set_status()
|
||||
self._set_popup_text()
|
||||
|
||||
def set_text(self) -> None:
|
||||
"""Set the text of the QLabel basae on the config."""
|
||||
service = self.config.service_name
|
||||
status = self.config.status
|
||||
if len(service.split("/")) > 1 and service.split("/")[0].startswith("BEC"):
|
||||
service = service.split("/", maxsplit=1)[0] + "/..." + service.split("/")[1][-4:]
|
||||
if status == "NOTCONNECTED":
|
||||
status = "NOT CONNECTED"
|
||||
text = f"{service} is {status}"
|
||||
self._label.setText(text)
|
||||
|
||||
def set_status(self) -> None:
|
||||
"""Set the status icon for the status item widget."""
|
||||
icon_path = IconsEnum[self.config.status].value
|
||||
icon = QIcon(icon_path)
|
||||
self._icon.setPixmap(icon.pixmap(*self.icon_size))
|
||||
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
def show_popup(self) -> None:
|
||||
"""Method that is invoked when the user double clicks on the StatusItem widget."""
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle(f"{self.config.service_name} Details")
|
||||
layout = QVBoxLayout()
|
||||
popup_label = self._make_popup_label()
|
||||
self._set_popup_text()
|
||||
layout.addWidget(popup_label)
|
||||
dialog.setLayout(layout)
|
||||
dialog.finished.connect(self._cleanup_popup_label)
|
||||
dialog.exec()
|
||||
|
||||
def _make_popup_label(self) -> QLabel:
|
||||
"""Create a QLabel for the popup dialog.
|
||||
|
||||
Returns:
|
||||
QLabel: The label for the popup dialog.
|
||||
"""
|
||||
label = QLabel()
|
||||
label.setWordWrap(True)
|
||||
self._popup_label_ref.update({"label": label})
|
||||
return label
|
||||
|
||||
def _set_popup_text(self) -> None:
|
||||
"""Compile the metrics text for the status item widget."""
|
||||
if self._popup_label_ref.get("label") is None:
|
||||
return
|
||||
metrics_text = (
|
||||
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
|
||||
)
|
||||
if self.config.metrics:
|
||||
for key, value in self.config.metrics.items():
|
||||
if key == "create_time":
|
||||
value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
|
||||
metrics_text += f"<b>{key.upper()}:</b> {value}<br>"
|
||||
self._popup_label_ref["label"].setText(metrics_text)
|
||||
|
||||
def _cleanup_popup_label(self) -> None:
|
||||
"""Cleanup the popup label."""
|
||||
self._popup_label_ref.clear()
|
||||
0
bec_widgets/widgets/color_button/__init__.py
Normal file
45
bec_widgets/widgets/color_button/color_button.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
|
||||
|
||||
class ColorButton(pg.ColorButton):
|
||||
"""
|
||||
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
|
||||
Patches event loop of the ColorDialog, if opened in another QDialog.
|
||||
"""
|
||||
|
||||
ICON_NAME = "colors"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def selectColor(self):
|
||||
self.origColor = self.color()
|
||||
self.colorDialog.setCurrentColor(self.color())
|
||||
self.colorDialog.open()
|
||||
self.colorDialog.exec()
|
||||
|
||||
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
|
||||
"""
|
||||
Get the color of the button in the specified format.
|
||||
|
||||
Args:
|
||||
format(Literal["RGBA", "HEX"]): The format of the returned color.
|
||||
|
||||
Returns:
|
||||
tuple|str: The color in the specified format.
|
||||
"""
|
||||
if format == "RGBA":
|
||||
return self.color().getRgb()
|
||||
if format == "HEX":
|
||||
return self.color().name()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the ColorButton.
|
||||
"""
|
||||
self.colorDialog.close()
|
||||
self.colorDialog.deleteLater()
|
||||
1
bec_widgets/widgets/color_button/color_button.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['color_button.py']}
|
||||
55
bec_widgets/widgets/color_button/color_button_plugin.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.color_button.color_button import ColorButton
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ColorButton' name='color_button'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ColorButton(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Buttons"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ColorButton.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "color_button"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ColorButton"
|
||||
|
||||
def toolTip(self):
|
||||
return "ColorButton which opens a color dialog."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/color_button/register_color_button.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.color_button.color_button_plugin import ColorButtonPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
0
bec_widgets/widgets/colormap_selector/__init__.py
Normal file
114
bec_widgets/widgets/colormap_selector/colormap_selector.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtGui import QColor, QFontMetrics, QImage
|
||||
from qtpy.QtWidgets import QApplication, QComboBox, QStyledItemDelegate, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class ColormapDelegate(QStyledItemDelegate):
|
||||
def __init__(self, parent=None):
|
||||
super(ColormapDelegate, self).__init__(parent)
|
||||
self.image_width = 25
|
||||
self.image_height = 10
|
||||
self.gap = 10
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
text = index.data()
|
||||
colormap = pg.colormap.get(text)
|
||||
colors = colormap.getLookupTable(start=0.0, stop=1.0, alpha=False)
|
||||
|
||||
font_metrics = QFontMetrics(painter.font())
|
||||
text_width = font_metrics.width(text)
|
||||
text_height = font_metrics.height()
|
||||
|
||||
total_height = max(text_height, self.image_height)
|
||||
|
||||
image = QImage(self.image_width, self.image_height, QImage.Format_RGB32)
|
||||
for i in range(self.image_width):
|
||||
color = QColor(*colors[int(i * (len(colors) - 1) / (self.image_width - 1))])
|
||||
for j in range(self.image_height):
|
||||
image.setPixel(i, j, color.rgb())
|
||||
|
||||
painter.drawImage(
|
||||
option.rect.x(), option.rect.y() + (total_height - self.image_height) // 2, image
|
||||
)
|
||||
painter.drawText(
|
||||
option.rect.x() + self.image_width + self.gap,
|
||||
option.rect.y() + (total_height - text_height) // 2 + font_metrics.ascent(),
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
class ColormapSelector(QWidget):
|
||||
"""
|
||||
Simple colormap combobox widget. By default it loads all the available colormaps in pyqtgraph.
|
||||
"""
|
||||
|
||||
colormap_changed_signal = Signal(str)
|
||||
ICON_NAME = "palette"
|
||||
|
||||
def __init__(self, parent=None, default_colormaps=None):
|
||||
super().__init__(parent=parent)
|
||||
self._colormaps = []
|
||||
self.initUI(default_colormaps)
|
||||
|
||||
def initUI(self, default_colormaps=None):
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.combo = QComboBox()
|
||||
self.combo.setItemDelegate(ColormapDelegate())
|
||||
self.combo.currentTextChanged.connect(self.colormap_changed)
|
||||
self.available_colormaps = pg.colormap.listMaps()
|
||||
if default_colormaps is None:
|
||||
default_colormaps = self.available_colormaps
|
||||
self.add_color_maps(default_colormaps)
|
||||
self.layout.addWidget(self.combo)
|
||||
|
||||
@Slot()
|
||||
def colormap_changed(self):
|
||||
"""
|
||||
Emit the colormap changed signal with the current colormap selected in the combobox.
|
||||
"""
|
||||
self.colormap_changed_signal.emit(self.combo.currentText())
|
||||
|
||||
def add_color_maps(self, colormaps=None):
|
||||
"""
|
||||
Add colormaps to the combobox.
|
||||
|
||||
Args:
|
||||
colormaps(list): List of colormaps to add to the combobox. If None, all available colormaps are added.
|
||||
"""
|
||||
self.combo.clear()
|
||||
if colormaps is not None:
|
||||
for name in colormaps:
|
||||
if name in self.available_colormaps:
|
||||
self.combo.addItem(name)
|
||||
else:
|
||||
for name in self.available_colormaps:
|
||||
self.combo.addItem(name)
|
||||
self._colormaps = colormaps if colormaps is not None else self.available_colormaps
|
||||
|
||||
@Property("QStringList")
|
||||
def colormaps(self):
|
||||
"""
|
||||
Property to get and set the colormaps in the combobox.
|
||||
"""
|
||||
return self._colormaps
|
||||
|
||||
@colormaps.setter
|
||||
def colormaps(self, value):
|
||||
"""
|
||||
Set the colormaps in the combobox.
|
||||
"""
|
||||
if self._colormaps != value:
|
||||
self._colormaps = value
|
||||
self.add_color_maps(value)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
ex = ColormapSelector()
|
||||
ex.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['colormap_selector.py']}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.colormap_selector.colormap_selector import ColormapSelector
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ColormapSelector' name='colormap_selector'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class ColormapSelectorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ColormapSelector(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Buttons"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ColormapSelector.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "colormap_selector"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ColormapSelector"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.colormap_selector.colormap_selector_plugin import (
|
||||
ColormapSelectorPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ColormapSelectorPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
497
bec_widgets/widgets/console/console.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
|
||||
embedded like any other Qt widget.
|
||||
|
||||
BECConsole is powered by Pyte, a Python based terminal emulator
|
||||
(https://github.com/selectel/pyte).
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import html
|
||||
import os
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pyte
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QSize, QSocketNotifier, Qt
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtGui import QClipboard, QTextCursor
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
"green": "#00CD00",
|
||||
"brown": "#996633", # Brown, replacing the yellow
|
||||
"blue": "#0000EE",
|
||||
"magenta": "#CD00CD",
|
||||
"cyan": "#00CDCD",
|
||||
"white": "#E5E5E5",
|
||||
"brightblack": "#7F7F7F",
|
||||
"brightred": "#FF0000",
|
||||
"brightgreen": "#00FF00",
|
||||
"brightyellow": "#FFFF00",
|
||||
"brightblue": "#5C5CFF",
|
||||
"brightmagenta": "#FF00FF",
|
||||
"brightcyan": "#00FFFF",
|
||||
"brightwhite": "#FFFFFF",
|
||||
}
|
||||
|
||||
control_keys_mapping = {
|
||||
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
|
||||
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
|
||||
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
|
||||
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
|
||||
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
|
||||
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
|
||||
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
|
||||
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
|
||||
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Return: b"\n",
|
||||
QtCore.Qt.Key_Space: b" ",
|
||||
QtCore.Qt.Key_Enter: b"\n",
|
||||
QtCore.Qt.Key_Tab: b"\t",
|
||||
QtCore.Qt.Key_Backspace: b"\x08",
|
||||
QtCore.Qt.Key_Home: b"\x47",
|
||||
QtCore.Qt.Key_End: b"\x4f",
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0E",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
QtCore.Qt.Key_F2: b"\x1b\x32",
|
||||
QtCore.Qt.Key_F3: b"\x1b\x33",
|
||||
QtCore.Qt.Key_F4: b"\x1b\x34",
|
||||
QtCore.Qt.Key_F5: b"\x1b\x35",
|
||||
QtCore.Qt.Key_F6: b"\x1b\x36",
|
||||
QtCore.Qt.Key_F7: b"\x1b\x37",
|
||||
QtCore.Qt.Key_F8: b"\x1b\x38",
|
||||
QtCore.Qt.Key_F9: b"\x1b\x39",
|
||||
QtCore.Qt.Key_F10: b"\x1b\x30",
|
||||
QtCore.Qt.Key_F11: b"\x45",
|
||||
QtCore.Qt.Key_F12: b"\x46",
|
||||
}
|
||||
|
||||
|
||||
def QtKeyToAscii(event):
|
||||
"""
|
||||
Convert the Qt key event to the corresponding ASCII sequence for
|
||||
the terminal. This works fine for standard alphanumerical characters, but
|
||||
most other characters require terminal specific control sequences.
|
||||
|
||||
The conversion below works for TERM="linux" terminals.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
# special case for MacOS
|
||||
# /!\ Qt maps ControlModifier to CMD
|
||||
# CMD-C, CMD-V for copy/paste
|
||||
# CTRL-C and other modifiers -> key mapping
|
||||
if event.modifiers() == QtCore.Qt.MetaModifier:
|
||||
if event.key() == Qt.Key_Backspace:
|
||||
return control_keys_mapping.get(Qt.Key_W)
|
||||
return control_keys_mapping.get(event.key())
|
||||
elif event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
if event.key() == Qt.Key_C:
|
||||
# copy
|
||||
return "copy"
|
||||
elif event.key() == Qt.Key_V:
|
||||
# paste
|
||||
return "paste"
|
||||
return None
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
if event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
return control_keys_mapping.get(event.key())
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
|
||||
|
||||
class Screen(pyte.HistoryScreen):
|
||||
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
|
||||
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
|
||||
self._fd = stdin_fd
|
||||
|
||||
def write_process_input(self, data):
|
||||
"""Response to CPR request for example"""
|
||||
os.write(self._fd, data.encode("utf-8"))
|
||||
|
||||
|
||||
class Backend(QtCore.QObject):
|
||||
"""
|
||||
Poll Bash.
|
||||
|
||||
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
|
||||
file descriptor of the Bash terminal.
|
||||
"""
|
||||
|
||||
# Signals to communicate with ``_TerminalWidget``.
|
||||
startWork = pyqtSignal()
|
||||
dataReady = pyqtSignal(object)
|
||||
|
||||
def __init__(self, fd, numColumns, numLines):
|
||||
super().__init__()
|
||||
|
||||
# File descriptor that connects to Bash process.
|
||||
self.fd = fd
|
||||
|
||||
# Setup Pyte (hard coded display size for now).
|
||||
self.screen = Screen(self.fd, numColumns, numLines, 10000)
|
||||
self.stream = pyte.ByteStream()
|
||||
self.stream.attach(self.screen)
|
||||
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._fd_readable)
|
||||
|
||||
def _fd_readable(self):
|
||||
"""
|
||||
Poll the Bash output, run it through Pyte, and notify the main applet.
|
||||
"""
|
||||
# Read the shell output until the file descriptor is closed.
|
||||
try:
|
||||
out = os.read(self.fd, 2**16)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# Feed output into Pyte's state machine and send the new screen
|
||||
# output to the GUI
|
||||
self.stream.feed(out)
|
||||
self.dataReady.emit(self.screen)
|
||||
|
||||
|
||||
class BECConsole(QtWidgets.QScrollArea):
|
||||
"""Container widget for the terminal text area"""
|
||||
|
||||
def __init__(self, parent=None, numLines=50, numColumns=125):
|
||||
super().__init__(parent)
|
||||
|
||||
self.innerWidget = QtWidgets.QWidget(self)
|
||||
QHBoxLayout(self.innerWidget)
|
||||
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
|
||||
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.innerWidget.layout().addWidget(self.term)
|
||||
|
||||
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
|
||||
self.innerWidget.layout().addWidget(self.scroll_bar)
|
||||
|
||||
self.term.set_scroll(self.scroll_bar)
|
||||
|
||||
self.setWidget(self.innerWidget)
|
||||
|
||||
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
|
||||
self.term._cmd = cmd
|
||||
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
|
||||
|
||||
def push(self, text):
|
||||
"""Push some text to the terminal"""
|
||||
return self.term.push(text)
|
||||
|
||||
|
||||
class _TerminalWidget(QtWidgets.QPlainTextEdit):
|
||||
"""
|
||||
Start ``Backend`` process and render Pyte output as text.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, numColumns=125, numLines=50, **kwargs):
|
||||
super().__init__(parent)
|
||||
|
||||
# file descriptor to communicate with the subprocess
|
||||
self.fd = None
|
||||
self.backend = None
|
||||
self.lock = threading.Lock()
|
||||
# command to execute
|
||||
self._cmd = None
|
||||
# should ctrl-d be deactivated ? (prevent Python exit)
|
||||
self._deactivate_ctrl_d = False
|
||||
|
||||
# Specify the terminal size in terms of lines and columns.
|
||||
self.numLines = numLines
|
||||
self.numColumns = numColumns
|
||||
self.output = [""] * numLines
|
||||
|
||||
# Use Monospace fonts and disable line wrapping.
|
||||
self.setFont(QtGui.QFont("Courier", 9))
|
||||
self.setFont(QtGui.QFont("Monospace"))
|
||||
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
|
||||
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
self._char_width = fmt.width("w")
|
||||
self._char_height = fmt.height()
|
||||
self.setCursorWidth(self._char_width)
|
||||
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
|
||||
|
||||
def start(self, deactivate_ctrl_d=False):
|
||||
self._deactivate_ctrl_d = deactivate_ctrl_d
|
||||
|
||||
# Start the Bash process
|
||||
self.fd = self.forkShell()
|
||||
|
||||
# Create the ``Backend`` object
|
||||
self.backend = Backend(self.fd, self.numColumns, self.numLines)
|
||||
self.backend.dataReady.connect(self.dataReady)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
width = self._char_width * self.numColumns
|
||||
height = self._char_height * self.numLines
|
||||
return QSize(width, height + 20)
|
||||
|
||||
def set_scroll(self, scroll):
|
||||
self.scroll = scroll
|
||||
self.scroll.setMinimum(0)
|
||||
self.scroll.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def scroll_value_change(self, value, old={"value": 0}):
|
||||
if value <= old["value"]:
|
||||
# scroll up
|
||||
# value is number of lines from the start
|
||||
nlines = old["value"] - value
|
||||
# history ratio gives prev_page == 1 line
|
||||
for i in range(nlines):
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
# scroll down
|
||||
nlines = value - old["value"]
|
||||
for i in range(nlines):
|
||||
self.backend.screen.next_page()
|
||||
old["value"] = value
|
||||
self.dataReady(self.backend.screen, reset_scroll=False)
|
||||
|
||||
@Slot(object)
|
||||
def keyPressEvent(self, event):
|
||||
"""
|
||||
Redirect all keystrokes to the terminal process.
|
||||
"""
|
||||
# Convert the Qt key to the correct ASCII code.
|
||||
if (
|
||||
self._deactivate_ctrl_d
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
and event.key() == QtCore.Qt.Key_D
|
||||
):
|
||||
return None
|
||||
|
||||
code = QtKeyToAscii(event)
|
||||
if code == "copy":
|
||||
# MacOS only: CMD-C handling
|
||||
self.copy()
|
||||
elif code == "paste":
|
||||
# MacOS only: CMD-V handling
|
||||
self._push_clipboard()
|
||||
elif code is not None:
|
||||
os.write(self.fd, code)
|
||||
|
||||
def push(self, text):
|
||||
"""
|
||||
Write 'text' to terminal
|
||||
"""
|
||||
os.write(self.fd, text.encode("utf-8"))
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = self.createStandardContextMenu()
|
||||
for action in menu.actions():
|
||||
# remove all actions except copy and paste
|
||||
if "opy" in action.text():
|
||||
# redefine text without shortcut
|
||||
# since it probably clashes with control codes (like CTRL-C etc)
|
||||
action.setText("Copy")
|
||||
continue
|
||||
if "aste" in action.text():
|
||||
# redefine text without shortcut
|
||||
action.setText("Paste")
|
||||
# paste -> have to insert with self.push
|
||||
action.triggered.connect(self._push_clipboard)
|
||||
continue
|
||||
menu.removeAction(action)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _push_clipboard(self):
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
self.push(clipboard.text())
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MiddleButton:
|
||||
# push primary selection buffer ("mouse clipboard") to terminal
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
if clipboard.supportsSelection():
|
||||
self.push(clipboard.text(QClipboard.Selection))
|
||||
return None
|
||||
elif event.button() == Qt.LeftButton:
|
||||
# left button click
|
||||
textCursor = self.textCursor()
|
||||
if textCursor.selectedText():
|
||||
# mouse was used to select text -> nothing to do
|
||||
pass
|
||||
else:
|
||||
# a simple 'click', make cursor going to end
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
|
||||
)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
|
||||
)
|
||||
self.setTextCursor(textCursor)
|
||||
self.ensureCursorVisible()
|
||||
return None
|
||||
return super().mouseReleaseEvent(event)
|
||||
|
||||
def dataReady(self, screenData, reset_scroll=True):
|
||||
"""
|
||||
Render the new screen as text into the widget.
|
||||
|
||||
This method is triggered via a signal from ``Backend``.
|
||||
"""
|
||||
with self.lock:
|
||||
# Clear the widget
|
||||
self.clear()
|
||||
|
||||
# Prepare the HTML output
|
||||
for line_no in screenData.dirty:
|
||||
line = text = ""
|
||||
style = old_style = ""
|
||||
for ch in screenData.buffer[line_no].values():
|
||||
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
|
||||
if style != old_style:
|
||||
if old_style:
|
||||
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
text = ""
|
||||
old_style = style
|
||||
text += ch.data
|
||||
if style:
|
||||
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
self.output[line_no] = line
|
||||
# fill the text area with HTML contents in one go
|
||||
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
|
||||
# done updates, all clean
|
||||
screenData.dirty.clear()
|
||||
|
||||
# Activate cursor
|
||||
textCursor = self.textCursor()
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
|
||||
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
|
||||
self.setTextCursor(textCursor)
|
||||
self.ensureCursorVisible()
|
||||
|
||||
# manage scroll
|
||||
if reset_scroll:
|
||||
self.scroll.valueChanged.disconnect(self.scroll_value_change)
|
||||
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
|
||||
self.scroll.setMaximum(tmp if tmp > 0 else 0)
|
||||
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
|
||||
self.scroll.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
# def resizeEvent(self, event):
|
||||
# with self.lock:
|
||||
# self.numColumns = int(self.width() / self._char_width)
|
||||
# self.numLines = int(self.height() / self._char_height)
|
||||
# self.output = [""] * self.numLines
|
||||
# print("RESIZING TO", self.numColumns, "x", self.numLines)
|
||||
# self.backend.screen.resize(self.numLines, self.numColumns)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
y = event.angleDelta().y()
|
||||
if y > 0:
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
self.backend.screen.next_page()
|
||||
self.dataReady(self.backend.screen, reset_scroll=False)
|
||||
|
||||
def forkShell(self):
|
||||
"""
|
||||
Fork the current process and execute bec in shell.
|
||||
"""
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
if pid == 0:
|
||||
# Safe way to make it work under BSD and Linux
|
||||
try:
|
||||
ls = os.environ["LANG"].split(".")
|
||||
except KeyError:
|
||||
ls = []
|
||||
if len(ls) < 2:
|
||||
ls = ["en_US", "UTF-8"]
|
||||
try:
|
||||
os.putenv("COLUMNS", str(self.numColumns))
|
||||
os.putenv("LINES", str(self.numLines))
|
||||
os.putenv("TERM", "linux")
|
||||
os.putenv("LANG", ls[0] + ".UTF-8")
|
||||
if isinstance(self._cmd, str):
|
||||
os.execvp(self._cmd, self._cmd)
|
||||
else:
|
||||
os.execvp(self._cmd[0], self._cmd)
|
||||
# print "child_pid", child_pid, sts
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
# self.proc_finish(sid)
|
||||
os._exit(0)
|
||||
else:
|
||||
# We are in the parent process.
|
||||
# Set file control
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
||||
print("Spawned Bash shell (PID {})".format(pid))
|
||||
return fd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
# Terminal size in characters.
|
||||
numLines = 25
|
||||
numColumns = 100
|
||||
|
||||
# Create the Qt application and QBash instance.
|
||||
app = QtWidgets.QApplication([])
|
||||
mainwin = QtWidgets.QMainWindow()
|
||||
title = "BECConsole ({}x{})".format(numColumns, numLines)
|
||||
mainwin.setWindowTitle(title)
|
||||
|
||||
console = BECConsole(mainwin, numColumns, numLines)
|
||||
mainwin.setCentralWidget(console)
|
||||
console.start()
|
||||
|
||||
# Show widget and launch Qt's event loop.
|
||||
mainwin.show()
|
||||
sys.exit(app.exec_())
|
||||
0
bec_widgets/widgets/dark_mode_button/__init__.py
Normal file
80
bec_widgets/widgets/dark_mode_button/dark_mode_button.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Qt, Slot
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
|
||||
class DarkModeButton(BECWidget, QWidget):
|
||||
USER_ACCESS = ["toggle_dark_mode"]
|
||||
|
||||
ICON_NAME = "dark_mode"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
toolbar: bool = False,
|
||||
) -> None:
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent)
|
||||
|
||||
self._dark_mode_enabled = False
|
||||
self.layout = QHBoxLayout(self)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
icon = material_icon("dark_mode", size=(20, 20), convert_to_pixmap=False)
|
||||
if toolbar:
|
||||
self.mode_button = QToolButton(icon=icon)
|
||||
else:
|
||||
self.mode_button = QPushButton(icon=icon)
|
||||
self.update_mode_button()
|
||||
self.mode_button.clicked.connect(self.toggle_dark_mode)
|
||||
self.layout.addWidget(self.mode_button)
|
||||
self.setLayout(self.layout)
|
||||
self.setFixedSize(40, 40)
|
||||
|
||||
@Property(bool)
|
||||
def dark_mode_enabled(self) -> bool:
|
||||
"""
|
||||
The dark mode state. If True, dark mode is enabled. If False, light mode is enabled.
|
||||
"""
|
||||
return self._dark_mode_enabled
|
||||
|
||||
@dark_mode_enabled.setter
|
||||
def dark_mode_enabled(self, state: bool) -> None:
|
||||
self._dark_mode_enabled = state
|
||||
|
||||
@Slot()
|
||||
def toggle_dark_mode(self) -> None:
|
||||
"""
|
||||
Toggle the dark mode state. This will change the theme of the entire
|
||||
application to dark or light mode.
|
||||
"""
|
||||
self.dark_mode_enabled = not self.dark_mode_enabled
|
||||
self.update_mode_button()
|
||||
set_theme("dark" if self.dark_mode_enabled else "light")
|
||||
|
||||
def update_mode_button(self):
|
||||
icon = material_icon(
|
||||
"light_mode" if self.dark_mode_enabled else "dark_mode",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
self.mode_button.setIcon(icon)
|
||||
self.mode_button.setToolTip("Set Light Mode" if self.dark_mode_enabled else "Set Dark Mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
w = DarkModeButton()
|
||||
w.show()
|
||||
|
||||
app.exec_()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['dark_mode_button.py']}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='DarkModeButton' name='dark_mode_button'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class DarkModeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = DarkModeButton(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Buttons"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(DarkModeButton.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "dark_mode_button"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "DarkModeButton"
|
||||
|
||||
def toolTip(self):
|
||||
return "Button to toggle between dark and light mode."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.dark_mode_button.dark_mode_button_plugin import DarkModeButtonPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(DarkModeButtonPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
0
bec_widgets/widgets/device_browser/__init__.py
Normal file
109
bec_widgets/widgets/device_browser/device_browser.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import QLineEdit, QListWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.device_browser.device_item import DeviceItem
|
||||
|
||||
|
||||
class DeviceBrowser(BECWidget, QWidget):
|
||||
device_update: Signal = Signal()
|
||||
|
||||
ICON_NAME = "lists"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
config=None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.ui = None
|
||||
self.ini_ui()
|
||||
|
||||
self.proxy_device_update = SignalProxy(
|
||||
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
|
||||
)
|
||||
self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self.device_update.connect(self.update_device_list)
|
||||
|
||||
self.update_device_list()
|
||||
|
||||
def ini_ui(self) -> None:
|
||||
"""
|
||||
Initialize the UI by loading the UI file and setting the layout.
|
||||
"""
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
|
||||
self.ui = UILoader(self).loader(ui_file_path)
|
||||
layout.addWidget(self.ui)
|
||||
self.setLayout(layout)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
|
||||
Args:
|
||||
action (str): The action that triggered the event.
|
||||
content (dict): The content of the config update.
|
||||
"""
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_update.emit()
|
||||
|
||||
@Slot()
|
||||
def update_device_list(self) -> None:
|
||||
"""
|
||||
Update the device list based on the filter input.
|
||||
There are two ways to trigger this function:
|
||||
1. By changing the text in the filter input.
|
||||
2. By emitting the device_update signal.
|
||||
|
||||
Either way, the function will filter the devices based on the filter input text and update the device list.
|
||||
"""
|
||||
filter_text = self.ui.filter_input.text()
|
||||
try:
|
||||
regex = re.compile(filter_text, re.IGNORECASE)
|
||||
except re.error:
|
||||
regex = None # Invalid regex, disable filtering
|
||||
|
||||
dev_list = self.ui.device_list
|
||||
dev_list.clear()
|
||||
for device in self.dev:
|
||||
if regex is None or regex.search(device):
|
||||
item = QListWidgetItem(dev_list)
|
||||
device_item = DeviceItem(device)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
dev_list.setItemWidget(item, device_item)
|
||||
dev_list.addItem(item)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
widget = DeviceBrowser()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['device_browser.py']}
|
||||
44
bec_widgets/widgets/device_browser/device_browser.ui
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>406</width>
|
||||
<height>500</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="browser_group_box">
|
||||
<property name="title">
|
||||
<string>Device Browser</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="filter_layout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="filter_input">
|
||||
<property name="placeholderText">
|
||||
<string>Filter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="device_list"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
54
bec_widgets/widgets/device_browser/device_browser_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.device_browser.device_browser import DeviceBrowser
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='DeviceBrowser' name='device_browser'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class DeviceBrowserPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = DeviceBrowser(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Services"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(DeviceBrowser.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "device_browser"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "DeviceBrowser"
|
||||
|
||||
def toolTip(self):
|
||||
return "DeviceBrowser"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1 @@
|
||||
from .device_item import DeviceItem
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QMimeData, Qt
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
|
||||
|
||||
class DeviceItem(QWidget):
|
||||
def __init__(self, device: str) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(10, 2, 10, 2)
|
||||
self.label = QLabel(device)
|
||||
layout.addWidget(self.label)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
"""
|
||||
)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.LeftButton:
|
||||
drag = QDrag(self)
|
||||
mime_data = QMimeData()
|
||||
mime_data.setText(self.device)
|
||||
drag.setMimeData(mime_data)
|
||||
drag.exec_(Qt.MoveAction)
|
||||
|
||||
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
|
||||
print("Double Clicked")
|
||||
# TODO: Implement double click action for opening the device properties dialog
|
||||
return super().mouseDoubleClickEvent(event)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = DeviceItem("Device")
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.device_browser.device_browser_plugin import DeviceBrowserPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceBrowserPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
0
bec_widgets/widgets/device_combobox/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"files": ["device_combobox.py"]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='DeviceComboBox' name='device_combobox'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = DeviceComboBox(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Device Control"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(DeviceComboBox.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "device_combobox"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "DeviceComboBox"
|
||||
|
||||
def toolTip(self):
|
||||
return "Device ComboBox Example for BEC Widgets"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
86
bec_widgets/widgets/device_combobox/device_combobox.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
|
||||
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase, DeviceInputConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputConfig
|
||||
|
||||
|
||||
class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
ICON_NAME = "list_alt"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: str | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
self.setMinimumSize(125, 26)
|
||||
self.populate_combobox()
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
if default is not None:
|
||||
self.set_default_device(default)
|
||||
|
||||
def set_device_filter(self, device_filter: str):
|
||||
"""
|
||||
Set the device filter.
|
||||
|
||||
Args:
|
||||
device_filter(str): Device filter, name of the device class.
|
||||
"""
|
||||
super().set_device_filter(device_filter)
|
||||
self.populate_combobox()
|
||||
|
||||
def set_default_device(self, default_device: str):
|
||||
"""
|
||||
Set the default device.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
super().set_default_device(default_device)
|
||||
self.setCurrentText(default_device)
|
||||
|
||||
def populate_combobox(self):
|
||||
"""Populate the combobox with the devices."""
|
||||
self.devices = self.get_device_list(self.config.device_filter)
|
||||
self.clear()
|
||||
self.addItems(self.devices)
|
||||
|
||||
def get_device(self) -> object:
|
||||
"""
|
||||
Get the selected device object.
|
||||
|
||||
Returns:
|
||||
object: Device object.
|
||||
"""
|
||||
device_name = self.currentText()
|
||||
device_obj = getattr(self.dev, device_name.lower(), None)
|
||||
if device_obj is None:
|
||||
raise ValueError(f"Device {device_name} is not found.")
|
||||
return device_obj
|
||||