1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-12 19:50:54 +02:00

Compare commits

...

167 Commits

Author SHA1 Message Date
15eebd500b fix(waveform): async data duplication if multiple signals from same device fixed 2025-05-06 16:39:54 +02:00
semantic-release
bc0e277332 2.1.2
Automatically generated by python-semantic-release
2025-05-06 11:09:41 +00:00
75a2780fe0 tests(user-interaction-e2e): add module scoped e2e tests with user interaction; closes #508 2025-05-06 11:28:12 +02:00
a6c479e42e build: remove flush-redis from ci job 2025-05-06 11:28:12 +02:00
64a4824054 fix(waveform): Ignore callbacks for on_async_readback from QtSender objects that are already destroyed; closes #497 2025-05-06 11:28:12 +02:00
1619446ec9 refactor(bec-status-box): add get_server_state user_access method to BECStatusBox 2025-05-06 11:28:12 +02:00
37f002427a refactor(bec-progressbar): add private method for bec_progressbar, udate client file 2025-05-06 11:28:12 +02:00
semantic-release
50cb70dcc6 2.1.1
Automatically generated by python-semantic-release
2025-05-06 08:37:48 +00:00
55f7efc4f5 fix: import add operator in client 2025-05-06 10:20:47 +02:00
be72c9f270 refactor: supply bec designer filename to function 2025-05-06 10:20:47 +02:00
c8cedc0124 wip 2025-05-06 08:54:36 +02:00
semantic-release
3fdbe4031e 2.1.0
Automatically generated by python-semantic-release
2025-05-05 11:10:40 +00:00
c16b9dce9c test(Dock): add validation for new dock creation with invalid name 2025-05-05 13:01:21 +02:00
9387275851 feat(SafeSlot): slot parameters can be overridden with kwarg; add option to raise 2025-05-05 13:01:21 +02:00
94463afdba fix: ensure rpc object do not collide with protected names 2025-05-05 13:01:21 +02:00
02563b10f3 refactor(colormap_widget): widget is rounded 2025-05-02 16:01:51 +02:00
fff4af2489 ci: install dev dependencies for formatter 2025-05-02 14:12:18 +02:00
452124b528 chore(formatter): upgrade to black v25 2025-05-02 14:12:18 +02:00
semantic-release
9c84e158ba 2.0.3
Automatically generated by python-semantic-release
2025-05-02 11:52:31 +00:00
58a0bc7974 fix(image_item): wrong user access name for rotation 2025-05-02 12:23:16 +02:00
770dbd4b63 fix(generate_cli): apply isort config 2025-05-02 12:23:16 +02:00
d22035f897 ci: add job to test that the generated client is up to date 2025-05-02 11:24:38 +02:00
semantic-release
fe21b39b7f 2.0.2
Automatically generated by python-semantic-release
2025-05-01 11:00:33 +00:00
1b78840fd8 fix(plot_base): no content margin for plot_widget window 2025-05-01 12:02:47 +02:00
semantic-release
46519342b6 2.0.1
Automatically generated by python-semantic-release
2025-04-30 11:52:51 +00:00
9079ddd727 fix(dock_area): restore state safeguard to not pass none to pyqtgraph restoreState 2025-04-30 13:14:16 +02:00
semantic-release
205745cc72 2.0.0
Automatically generated by python-semantic-release
2025-04-29 17:22:28 +00:00
717017e69e doc(image): rotation update 2025-04-29 19:06:47 +02:00
a3de1f0a31 refactor(plots): waveform and image rpc api review 2025-04-29 18:37:53 +02:00
8eef4253b0 feat(slot): add 'verify_sender' argument to SafeSlot for sender verification 2025-04-29 17:49:01 +02:00
1f2db927f5 fix(scan_control): restore scan parameters always regenerate the arg box, preventing infinite loop 2025-04-29 17:38:56 +02:00
98f159b25f fix(image): ImageItem remove adjusted to disconnect and remove current displayed image 2025-04-29 16:31:11 +02:00
061f3481da fix(becconnector): widgets can be flagged as root widget, skipping the BECMainWindow in CLI usage 2025-04-29 16:16:35 +02:00
f35f4c4b29 fix(becconnector,widgets): parent_id is always fetched from the real bec widget parent; all widgets adjusted; hardcoded parent_ids removed 2025-04-29 13:23:09 +02:00
c36852b2ef fix(rpc_server): broadcasted data check 2025-04-29 11:48:35 +02:00
4eaadd1545 fix(scan_matadata): parent passing 2025-04-29 11:35:10 +02:00
David Perl
d04770fe91 refactor: rearrange base of metadata forms for generic use 2025-04-29 11:35:10 +02:00
23fee22ef8 test: fix tests for launcher close / hide behavior 2025-04-29 10:09:47 +02:00
6e7920c119 fix(launcher): hide launcher when launcher is closed even though it is not the last widget 2025-04-29 09:43:19 +02:00
e3d0d5566c test: add IPython client GUI object test module with tab completion 2025-04-28 15:38:50 +02:00
e5b532274e refactor(assets): new icon for ui loader 2025-04-28 14:20:42 +02:00
eb0323b989 build(dependencies): update min bec_lib version to 3.29 2025-04-28 08:39:05 +02:00
60852e228f docs: replaces instances of QtDesigner with BEC Designer for improved clarity 2025-04-27 16:58:40 +02:00
b3dbe922de fix(launch_window): return None when cancelling the ui file launcher 2025-04-27 13:50:43 +02:00
fde912005d fix(cleanup): prevent double cleanup by tracking object destruction state 2025-04-27 13:45:58 +02:00
5e4965fe1f docs(lmfit): fix links 2025-04-25 20:29:26 +02:00
aff5a51f4c fix(type hints): add future import to prevent sphinx from crashing 2025-04-25 20:29:26 +02:00
b4af2cc77a docs: updated docs for v2 (#531) 2025-04-25 20:29:26 +02:00
25bd905cef docs: update docs for v2 2025-04-25 20:08:21 +02:00
2f0d213e32 docs(position-indicator): update docs for positioner indicator 2025-04-25 19:41:20 +02:00
b6695b45d0 docs: update docs for various widgets 2025-04-25 19:41:20 +02:00
77f9d42576 fix: unique name for widgets, fix new method for docks; closes #534 2025-04-25 19:41:20 +02:00
8cca510fa1 fix(client): import reduce 2025-04-25 16:59:53 +02:00
06a4954d3d fix(BECGuiClient): add launch_script parameter to dock area creation 2025-04-24 17:39:55 +02:00
4acf5befb1 docs: review quick_start 2025-04-24 14:38:07 +02:00
99d76236ca test: add tests for name creation of custom curves, and object name handling 2025-04-24 08:49:33 +02:00
afc818bf7d docs: update quick_start 2025-04-24 08:49:33 +02:00
8e846d4499 fix(curve): fix unique names for custom curves 2025-04-24 08:49:33 +02:00
a1c859c743 docs: remove BECFigure from docs, fix wrong api for docs of plotting widgets 2025-04-24 08:49:33 +02:00
75cc45d767 docs: remove BECFigure 2025-04-24 08:49:33 +02:00
1d091071e1 fix: bugfix in cleanup of ScatterWaveform ScatterCurve; closes #520 2025-04-24 08:49:33 +02:00
8e64b65c2d feat: delete bec_app 2025-04-24 08:49:33 +02:00
27ea92d120 feat: deprecated and delete alignment_1d gui 2025-04-24 08:49:33 +02:00
3ddfeaa49f fix(serialization): add serialization for qpointf 2025-04-23 20:42:54 +02:00
074bbbc166 fix: change default colormap to plasma 2025-04-23 19:05:54 +02:00
3709cdc866 fix(bec_connector): improve cleanup handling on deleted parent to prevent errors 2025-04-23 17:45:58 +02:00
9d6d0b406a refactor(bec_connector): replace pyqtSlot with SafeSlot for consistency 2025-04-23 17:45:58 +02:00
6318b2d822 fix(designer-plugin-generator): enhance super constructor validation for new style classes 2025-04-23 17:45:58 +02:00
f89e74b199 refactor: add template for debugging the cli generator 2025-04-23 17:45:58 +02:00
0ac14a74b8 fix: ensure provided dock and dock_area names are valid and defaults are snake_case 2025-04-23 16:22:13 +02:00
1910993b2b fix(positioner-indicator): fix property setters for position indicator 2025-04-23 14:00:06 +02:00
7c303d0129 fix(ring-progress-bar): fix bug in disconnect slot of rings, enable 'scan' mode as default for init with first ring 2025-04-23 07:30:07 +02:00
113938e71a test: fix rpc widgets e2e test 2025-04-22 21:19:37 +02:00
e0f146beeb fix(compact_popup): forward close event 2025-04-22 21:19:37 +02:00
fc1cdc814f fix(bec_connector): call cleanup on widgets if the parent was deleted 2025-04-22 21:19:37 +02:00
a13de45131 fix(rpc): call close on container widget if needed 2025-04-22 21:19:37 +02:00
8ff2063bc8 fix: proper cleanup of progressbar 2025-04-22 21:19:37 +02:00
cdc613b6e7 fix(bec_queue): set parent for toolbar buttons 2025-04-22 21:19:37 +02:00
1fc6125369 fix: forward parent to children 2025-04-22 21:19:37 +02:00
fef07ac8e1 fix: import from qtpy instead of PySide6 2025-04-22 21:19:37 +02:00
86647b9b7e fix(rpc-base): deprecate widget_name in favor of object_name; closes #499 2025-04-22 21:19:37 +02:00
36dc174bfe test: add function scoped rpc_widgets e2e test; closes #510 2025-04-22 21:19:37 +02:00
a06f0600c1 fix(dark-mode-button): fix parent passed to QObjects in various classes 2025-04-22 21:19:37 +02:00
f88dfc8f1b refactor: add pragma no cover to various TYPE_CHECKING 2025-04-22 21:19:37 +02:00
c70cd9d6e8 fix(moduar-toolbar): fix cleanup of modular toolbar and dock_area 2025-04-22 21:19:37 +02:00
8fbd54c3aa fix(website-widget): add super().cleanup() in website widget 2025-04-22 21:19:37 +02:00
ef4a52cc17 fix: RPC access enabled for certain widgets. 2025-04-22 21:19:37 +02:00
b460ea9955 fix(progress-ring-bar): fix parent inheritance and cleanup of ring objects; closes #496 2025-04-22 21:19:37 +02:00
1fe052e9da docs: grammar improvement 2025-04-22 15:22:18 +02:00
f2d5b57e86 fix(docs): update copyright year to be dynamic 2025-04-22 15:22:18 +02:00
6630ba1c42 docs(auto_updates): update documentation for auto updates functionality and add launcher image 2025-04-22 15:22:18 +02:00
ef148317de fix: wrap fetching plugin widgets in case of errors 2025-04-15 20:13:11 +02:00
e10f5ec088 test(launch_window): tests for default and plugin auto updates 2025-04-15 12:26:09 +02:00
33a8a767f3 test(launch_window): add test for launching UI file that raises ValueError for QMainWindow 2025-04-15 12:08:06 +02:00
8efa93d2d2 feat(launch_window): add user access permissions 2025-04-15 12:07:54 +02:00
29653239c5 feat(launch_window): enhance auto update functionality with selector and dynamic loading 2025-04-15 11:44:26 +02:00
778230b5ed feat(auto_updates): enforce rpc widget class for subclasses of auto updates 2025-04-15 11:41:03 +02:00
b7795b4d0a refactor(client_utils): remove unused auto update attributes from BECGuiClient 2025-04-15 11:40:22 +02:00
c434af9b92 feat(plugin_utils): add functionality to retrieve auto update classes from plugins 2025-04-15 11:40:04 +02:00
be722683a7 fix(main_window): show app id only when connected to redis 2025-04-15 09:10:35 +02:00
9a940bb8d5 refactor(launch_window): remove cleanup method 2025-04-15 08:59:17 +02:00
a6ce312f7c refactor(ui_loader): remove unused import 2025-04-15 08:58:59 +02:00
d5e422c7fc test(launch_window): add unit tests for LaunchWindow initialization and custom UI file launching 2025-04-15 08:58:14 +02:00
3cd6e05b24 fix(launch_window): update LaunchTile icon to use new UI loader tile image 2025-04-14 21:56:27 +02:00
3089ca15ec feat(launch_window): add custom UI file launching functionality and UI tile 2025-04-14 21:42:22 +02:00
d60cf6c843 refactor(ui_loader): remove unnecessary parent_id handling 2025-04-14 21:41:54 +02:00
45cd82e635 feat(ui_launch_window): add UILaunchWindow class 2025-04-14 21:40:46 +02:00
f653fc5f7e feat(positioner_box): add units QLabel to device UI components and update visibility logic 2025-04-14 13:33:11 +02:00
d6fccd10f5 fix(rpc_server): update _serialize_bec_connector to include wait parameter for registration check 2025-04-14 10:26:31 +02:00
064343acf2 fix(bec_connector): add setObjectName method to update object name and broadcast if registered; closes #472 2025-04-14 10:26:31 +02:00
82b82659b7 fix(rpc_register): change add_rpc parameter type to BECConnector and add object_is_registered method 2025-04-14 10:26:31 +02:00
1921444e15 fix(bec_connector): add assertion to ensure BECConnector is used with a QObject; closes #475 2025-04-14 10:26:31 +02:00
3b16c9f5a2 fix(bec_connector): move RPC registration into single shot method to ensure the rpc name is in sync 2025-04-14 10:26:31 +02:00
4381fcc4c2 fix(designer): avoid touching deleted widgets during init as QtDesigner will segfault 2025-04-14 10:26:31 +02:00
e4e9febc98 fix(ring_progress_bar): replaced hard-coded endpoints by MessageEndpoints 2025-04-14 10:16:47 +02:00
ac9224e5f2 refactor(auto_updates): move cleanup method from user section to internal section 2025-04-14 10:04:43 +02:00
18e4ba6cfe fix(auto_updates): fix condition to skip auto update 2025-04-14 10:04:43 +02:00
cfc8272ac2 docs: add missing class doc strings for rpc-enabled widgets 2025-04-12 21:14:01 +02:00
d2c90757c2 docs: better document logpanel code 2025-04-11 18:27:28 +02:00
1d7b423bb3 fix: warning in logpanel
- chain a signal to the child BecLogsQueue rather than passing the
signal instance in
2025-04-11 18:27:28 +02:00
cb91ebc0c3 refactor(rpc_server): add type hint for _get_becwidget_ancestor method parameter; minor cleanup of imports 2025-04-11 13:39:26 +02:00
08168f28d3 refactor(rpc_server): add type hints and docstrings for heartbeat and registry update methods 2025-04-11 13:37:42 +02:00
125afc8907 fix(rpc_server): enhance serialization logic for BECConnector objects and fix return types 2025-04-11 13:34:05 +02:00
4dc59aa5e9 fix(rpc_base): ensure message wait event is set after processing RPC response 2025-04-11 13:28:28 +02:00
96b31a4509 fix(client_utils): simplify RPC client instantiation in BECGuiClient 2025-04-11 13:25:10 +02:00
20a86ad325 fix(server): turn_off_the_lights cleanup fixed for parent_id widgets 2025-04-11 10:54:45 +02:00
7e65d4f2d6 fix(launch_window): redesign 2025-04-11 10:54:45 +02:00
11feeff37c fix(main_window): connected to theme change 2025-04-11 10:45:28 +02:00
c1bbb16dad fix(round_frame): orientation can be vertical 2025-04-11 10:45:28 +02:00
a5f1f4781e build(bec_lib): raised required version to 3.28.1 2025-04-11 10:45:28 +02:00
56c2827140 refactor(auto_update): auto_update changed to be BECMainWindow; removed auto update logic from BECDockArea 2025-04-11 10:45:28 +02:00
b03d2eaeed fix(waveform): dap curve flickering 2025-04-11 10:45:28 +02:00
3a82c95f60 fix(waveform, rpc_reference): __getitem__ removed form waveform and rpc_reference 2025-04-11 10:45:28 +02:00
5f272a66a4 feat(auto_update): add GUI highlight management for auto updates status 2025-04-11 10:45:28 +02:00
55baa84eb6 feat(main_window): add launcher menu and functionality to show launcher 2025-04-11 10:45:28 +02:00
b51d637c5f test(plot_base): test for plot base re-enabled 2025-04-11 10:45:28 +02:00
c97db6aaae fix(client): regenerated client 2025-04-11 10:45:28 +02:00
e725de3c45 fix(dock_area): close BECMainWindow if dock area is central widget 2025-04-11 10:45:28 +02:00
6082e7a690 refactor(rpc_server): cli_server renamed to rpc_server 2025-04-11 10:45:28 +02:00
8914f1d506 test(setting_dialog): test that settings reject calls cleanup 2025-04-11 10:45:28 +02:00
d06605122e test: qapp must shutdown cli server before checking for leaked QTimer 2025-04-11 10:45:28 +02:00
a8adb064f5 test(generate_cli): fix reference output 2025-04-11 10:45:28 +02:00
31c3b64d7b test(device_signal_input): fix init of device input widget 2025-04-11 10:45:28 +02:00
23bdd95d8c test(bec_connector): BECConnector requires a QObject 2025-04-11 10:45:28 +02:00
d1712552ff fix(cli): add type ignore comment to generated files 2025-04-11 10:45:28 +02:00
20a1c5ddb3 feat(launcher): add option for launching with auto updates 2025-04-11 10:45:28 +02:00
2511056557 feat!: add support for auto updates 2025-04-11 10:45:27 +02:00
99383b7715 refactor(launcher,main_window): launcher window moved to inherit from BECMainWindow 2025-04-11 10:45:27 +02:00
337a332ed1 fix(plot_framework): all widgets, popups and side menus cleanups adjusted 2025-04-11 10:45:27 +02:00
a1bec75115 fix(widgets)!: BECConnector resolves hierarchy including objectName, parent, parent_id upon init; all widgets adjusted 2025-04-11 10:45:27 +02:00
a2128ad8d6 fix(RPCReference): setattr added 2025-04-10 16:11:59 +02:00
5f27a90989 feat(server,launcher)!: RPC server separated with the launcher window introduced 2025-04-10 16:11:59 +02:00
39164feb18 fix(waveform): signals for x device can be defined from gui 2025-04-09 23:52:31 +02:00
af28e2e433 fix: support auto_range_x/y for viewAll during measurement 2025-04-09 14:35:52 +02:00
515d7ad055 refactor: add fallback to 'index' plotting in case of missmatch in length 2025-04-09 14:35:52 +02:00
0e276d4c09 refactor: add support to plot against x_data 2025-04-09 14:35:52 +02:00
ed2d958de6 refactor: improve plotting behaviour from history 2025-04-09 14:35:52 +02:00
25820a1cde refactor: set downsampling to auto=True, method 'peak', activate clipToView for (Async)-Curves and fix ViewAll hook from pg.view_box menu 2025-04-09 14:35:52 +02:00
7f7891dfa5 fix: add support for 'add_slice', add downsampling for performance improvements. add tests 2025-04-09 14:35:52 +02:00
b5015e4e72 built: cleanup gitlab-ci, remove pyqt6 related lines 2025-04-08 14:45:47 +02:00
7653e0877c hack: comment out segfaulting test 2025-04-07 14:19:37 +02:00
52a9f29bdc docs: add docs on widget plugins 2025-04-07 14:19:37 +02:00
ca2bb4f9b4 feat: add loader/helper for widget plugins 2025-04-07 14:19:37 +02:00
b4925918f7 refactor: tidy client generation and add options 2025-04-03 16:12:57 +02:00
43e1aa9505 fix: add designer plugin for ScanMetadata 2025-04-03 16:12:57 +02:00
28ae0d2b57 fix: expose common classes from bec_widgets package 2025-04-03 16:12:57 +02:00
7726d83b68 fix: create widget enum programatically 2025-04-03 16:12:57 +02:00
205 changed files with 7412 additions and 3517 deletions

View File

@@ -13,7 +13,7 @@ variables:
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
@@ -35,8 +35,7 @@ include:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
pip_args: ".[dev]"
# different stages in the pipeline
stages:
@@ -78,7 +77,7 @@ formatter:
stage: Formatter
needs: []
script:
- pip install bec_lib[dev]
- pip install -e ./[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
@@ -89,7 +88,7 @@ pylint:
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev,pyqt6]
- pip install -e .[dev]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
@@ -163,6 +162,20 @@ tests:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
matrix:
@@ -190,7 +203,7 @@ test-matrix:
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3
image: continuumio/miniconda3:25.1.1-2
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
@@ -217,7 +230,7 @@ end-2-end-conda:
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
artifacts:
when: on_failure
@@ -232,7 +245,7 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
semver:
stage: Deploy

View File

@@ -1,6 +1,737 @@
# CHANGELOG
## v2.1.2 (2025-05-06)
### Bug Fixes
- **waveform**: Ignore callbacks for on_async_readback from QtSender objects that are already
destroyed; closes #497
([`64a4824`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64a48240546846fdf4541c2adf3a0a5a0829f948))
### Build System
- Remove flush-redis from ci job
([`a6c479e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a6c479e42ea2a47c45e5a323bb3072bab503ecf1))
### Refactoring
- **bec-progressbar**: Add private method for bec_progressbar, udate client file
([`37f0024`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/37f002427ad5da01164ae3b0f4983695fe61c243))
- **bec-status-box**: Add get_server_state user_access method to BECStatusBox
([`1619446`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1619446ec9839cfa1c666a3790a0c2abc449c4a8))
## v2.1.1 (2025-05-06)
### Bug Fixes
- Import add operator in client
([`55f7efc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/55f7efc4f586128dfb66fc6a8eb5d3a9f32bf61e))
### Refactoring
- Supply bec designer filename to function
([`be72c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be72c9f2708c93dab24d4383f5622e38cf1dc8a2))
## v2.1.0 (2025-05-05)
### Bug Fixes
- Ensure rpc object do not collide with protected names
([`94463af`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/94463afdba11fe2da5958a371ef49572889b8622))
### Chores
- **formatter**: Upgrade to black v25
([`452124b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/452124b528c41db14d1e34ab98db95f6f7230ad6))
### Continuous Integration
- Install dev dependencies for formatter
([`fff4af2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fff4af2489bdea0cf4f6f8db68db59fba411c25e))
### Features
- **SafeSlot**: Slot parameters can be overridden with kwarg; add option to raise
([`9387275`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/93872758517177503b1f868376a6095670131844))
### Refactoring
- **colormap_widget**: Widget is rounded
([`02563b1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02563b10f3c90bddc069446dfe4137aa5a9727cb))
### Testing
- **Dock**: Add validation for new dock creation with invalid name
([`c16b9dc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c16b9dce9ce629b794d731cd7f3282a59f8b8c59))
## v2.0.3 (2025-05-02)
### Bug Fixes
- **generate_cli**: Apply isort config
([`770dbd4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/770dbd4b63baba588871a4d4ffa77d44872d085b))
- **image_item**: Wrong user access name for rotation
([`58a0bc7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58a0bc79742e7e7578988711a9840ed6041d9a69))
### Continuous Integration
- Add job to test that the generated client is up to date
([`d22035f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d22035f8974ac51ae1b6efc0e2b3749ca0a674ff))
## v2.0.2 (2025-05-01)
### Bug Fixes
- **plot_base**: No content margin for plot_widget window
([`1b78840`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1b78840fd87ea0f156c73beeb57c6c06f685f7b1))
## v2.0.1 (2025-04-30)
### Bug Fixes
- **dock_area**: Restore state safeguard to not pass none to pyqtgraph restoreState
([`9079ddd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9079ddd7278ede7a9a12d7b39797154e83659c20))
## v2.0.0 (2025-04-29)
### Bug Fixes
- Add designer plugin for ScanMetadata
([`43e1aa9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/43e1aa9505cfa6e87b4fce1d065efb48b4111190))
- Add support for 'add_slice', add downsampling for performance improvements. add tests
([`7f7891d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7f7891dfa54588f5d902448b760f141b183a7fa1))
- Broadcast context manager to emit registry changes just once
([`a5f06c8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a5f06c8f8380156a763445a69df29ee0e62e434c))
- Bugfix in cleanup of ScatterWaveform ScatterCurve; closes #520
([`1d09107`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1d091071e1179821bb1dcd47fb97f3d0959b972f))
- Change default colormap to plasma
([`074bbbc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/074bbbc16648849bdfcfc28b2c520b0e38dd07c2))
- Create widget enum programatically
([`7726d83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7726d83b6834b8145e48e709e2f839fb0ec1b971))
- Ensure provided dock and dock_area names are valid and defaults are snake_case
([`0ac14a7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0ac14a74b851578fff668fb8c6722f990130831d))
- Expose common classes from bec_widgets package
([`28ae0d2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/28ae0d2b577d7c926ee54690898fe8e327e1229f))
- Forward parent to children
([`1fc6125`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1fc61253699425a2bf64a0f8b560f8474549b841))
- Import from qtpy instead of PySide6
([`fef07ac`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fef07ac8e12399e7e49bcd673a5fc7cbf713bc50))
- Proper cleanup of progressbar
([`8ff2063`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8ff2063bc8978c5b2a97f720d5da055e8ec08f0c))
- Rpc access enabled for certain widgets.
([`ef4a52c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef4a52cc17748f35ed627170b1025e6e028d70b8))
- Server shutdown widgets
([`75b2446`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/75b24467def65284ea6b6114b25098437e31ec95))
- Support auto_range_x/y for viewAll during measurement
([`af28e2e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af28e2e433c9b0233436da850be97cd63df90a74))
- Unique name for widgets, fix new method for docks; closes #534
([`77f9d42`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/77f9d425765061f137c997062a3bf769a939bc64))
- Warning in logpanel
([`1d7b423`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1d7b423bb307b6aae3879987776310c14380895d))
- chain a signal to the child BecLogsQueue rather than passing the signal instance in
- Wrap fetching plugin widgets in case of errors
([`ef14831`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef148317dea9c7ff985b2a3ff06ccdb37258153f))
- **auto_updates**: Fix condition to skip auto update
([`18e4ba6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/18e4ba6cfe9f67512efbd3989156de5670aab3fe))
- **bec_connector**: Add assertion to ensure BECConnector is used with a QObject; closes #475
([`1921444`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1921444e152e06c4decc790452f3c496cf8ee961))
- **bec_connector**: Add setObjectName method to update object name and broadcast if registered;
closes #472
([`064343a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/064343acf2631e4ae62b2a5e08bc08087246570c))
- **bec_connector**: Call cleanup on widgets if the parent was deleted
([`fc1cdc8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc1cdc814fc3c44a571c20986bc627935f90ff91))
- **bec_connector**: Improve cleanup handling on deleted parent to prevent errors
([`3709cdc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3709cdc86671e5219afca7a8e11bdd01f03dd30e))
- **bec_connector**: Move RPC registration into single shot method to ensure the rpc name is in sync
([`3b16c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3b16c9f5a2f7f16b23f25560b1e8fb4e42359ef0))
- **bec_queue**: Set parent for toolbar buttons
([`cdc613b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cdc613b6e7d7eac806d458515321590e9344244a))
- **becconnector**: Widgets can be flagged as root widget, skipping the BECMainWindow in CLI usage
([`061f348`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/061f3481daae6844a83c44e9caca7ed56a1bb100))
- **becconnector,widgets**: Parent_id is always fetched from the real bec widget parent; all widgets
adjusted; hardcoded parent_ids removed
([`f35f4c4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f35f4c4b295139b99a2dad9e8241f900d2565aeb))
- **BECGuiClient**: Add launch_script parameter to dock area creation
([`06a4954`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/06a4954d3da44c6805232d34e47e242b28ba7fd1))
- **cleanup**: Prevent double cleanup by tracking object destruction state
([`fde9120`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fde912005db61a60707e7181c3425a4557bdc011))
- **cli**: Add type ignore comment to generated files
([`d171255`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d1712552ffd1118845dc7121218df86ce10e8750))
- **client**: Import reduce
([`8cca510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8cca510fa1cbda00a07edbef9d36fdd74e63d201))
- **client**: Regenerated client
([`c97db6a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c97db6aaae81d08019a13c344414c16c42691654))
- **client**: Rpc API adjusted for DockArea, ImageItem and Waveform
([`6ca4aa0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ca4aa0f9b9d5ace9fb1e174219f4da5617ebbac))
- **client_utils**: Simplify RPC client instantiation in BECGuiClient
([`96b31a4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96b31a450998aca2b7ac94138b07223418d2bacd))
- **colormap_widget**: Size policy fixed
([`1cc2a98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1cc2a9848906a7013e86687976d42d4b9676b25f))
- **compact_popup**: Forward close event
([`e0f146b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e0f146beeb34367a4d3454a7012af4728d594b9b))
- **crosshair**: Adapted for 2D image
([`a85402d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a85402dde1af1d9c4a154892c46422ac3e1f22f9))
- **curve**: Fix unique names for custom curves
([`8e846d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e846d449955ded3cb8090e44ea36d26efccb80e))
- **dark-mode-button**: Fix parent passed to QObjects in various classes
([`a06f060`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a06f0600c1c9a80436f01533a82905a6f3633895))
- **designer**: Avoid touching deleted widgets during init as QtDesigner will segfault
([`4381fcc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4381fcc4c212cd03ce91f1638dc361c3315f8c45))
- **designer-plugin-generator**: Enhance super constructor validation for new style classes
([`6318b2d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6318b2d822be0ded561a1afd0d485158614e2406))
- **device_input_base**: Removed enums from Pydantic models to make them serialisable
([`43b747e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/43b747ec8a761530d78b26650b0ec2ee4581ffaf))
- **dock_area**: Close BECMainWindow if dock area is central widget
([`e725de3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e725de3c4504d43fbcad25d69c5cb8cbe7a70867))
- **docs**: Update copyright year to be dynamic
([`f2d5b57`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f2d5b57e86d0c9d690b8d9f988035427608f0b4c))
- **entry_validator**: Validator reports list of signal if user chooses the wrong one
([`da05877`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/da05877dd04fa618cdb45268cb62df602a5e808f))
- **image**: Imageitem remove adjusted to disconnect and remove current displayed image
([`98f159b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/98f159b25f6bf7e1f2dd76726d7ab66a0baf88de))
- **launch_window**: Redesign
([`7e65d4f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7e65d4f2d6d840d3895e023f5cd090a56ea6e5f3))
- **launch_window**: Return None when cancelling the ui file launcher
([`b3dbe92`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3dbe922dea2cea9190d1583bd6b69f1a45d6b90))
- **launch_window**: Update LaunchTile icon to use new UI loader tile image
([`3cd6e05`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3cd6e05b2478654210049ca8e1756ad592f1da81))
- **launcher**: Hide launcher when launcher is closed even though it is not the last widget
([`6e7920c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e7920c119824650006e7357ca2f4ff95d413e13))
- **lmfit_dialog_vertical**: Vertical sizePolicy fixed
([`584b945`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/584b94500565a33e5daed86b7552ec54f1135cf6))
- **main_window**: Connected to theme change
([`11feeff`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11feeff37ce0b02fcbc8e506c67c14e1fc5e0cb6))
- **main_window**: Show app id only when connected to redis
([`be72268`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be722683a7cc7b215c572f9c2e996839b010b64e))
- **moduar-toolbar**: Fix cleanup of modular toolbar and dock_area
([`c70cd9d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c70cd9d6e8f7ea9d5f81b10ac437cdcc9ee900e9))
- **motor_map**: Limit map creating optimized
([`9f2a083`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9f2a083abbcfb465ebea9acee8263dcc9a6da5d9))
- **plot_base**: Ability to set y label suffix
([`890b501`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/890b50115fef845c2a77242fdb05863d2eec4a00))
- **plot_base**: Aspect ratio removed from the PlotBase
([`19d8aeb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/19d8aeb16249a1093cfec124d0ebdf6af11d94a8))
- **plot_base**: Axis setting filter for relevant properties
([`0204d9c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0204d9c86f9665dcefdcbe7f49ac23918d74dd66))
- **plot_base**: Do not enable inner axes when label is changed
([`98eda03`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/98eda03f4d6d449605d5559a1db44c900d93cb79))
- **plot_base**: Enable popup property fixed
([`30db183`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30db18367e9c6d6375fda970a1bb255d966cba5a))
- **plot_base**: Fix cleanup of popups if popups are still open when PlotBase is closed
([`39cf4dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/39cf4ddd5a033ee7f589d508f765669186e776bc))
- **plot_base**: Improved handling of matplotlib exporter errors
([`4f9514f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f9514fbd1ff0059248d3b7b5b4fcd85c3eb9c72))
- **plot_base**: Inner and outer axis setting in popup mode
([`055b968`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/055b96818aa69d66119caee9a3e8c24575ce60b4))
- **plot_base**: Update mouse mode state on mode change
([`fc24c8b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc24c8b3a5f9cd55fb3d49f753b53a65a2a0fa26))
- **plot_framework**: All widgets, popups and side menus cleanups adjusted
([`337a332`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/337a332ed123f99729b8cf6869f7fe4b056c2b16))
- **plot_indicators**: Cleanup adjusted
([`4865341`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48653410101a2a38d5067fbfca7712d255d89625))
- **plot_indicators**: Plot indicators added to the PlotBase
([`42e3b9c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/42e3b9c13786e67220874a1275a3d9ee9515541a))
- **positioner-indicator**: Fix property setters for position indicator
([`1910993`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1910993b2b3d30ecb8e4977b4a362f46adae3c75))
- **progress-ring-bar**: Fix parent inheritance and cleanup of ring objects; closes #496
([`b460ea9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b460ea9955318879ddfe4f9ae963249ba342bbb5))
- **ring-progress-bar**: Fix bug in disconnect slot of rings, enable 'scan' mode as default for init
with first ring
([`7c303d0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7c303d01294493a55fd26db8c1475e8c58b3e492))
- **ring_progress_bar**: Replaced hard-coded endpoints by MessageEndpoints
([`e4e9feb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e4e9febc98268a4b6b9774b253419e88ea044811))
- **round_frame**: Orientation can be vertical
([`c1bbb16`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1bbb16dad481c628e7680180d7250ba8a560c46))
- **round_frame**: Roundframe removed from BECWidget inheritance
([`b58a098`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b58a098ed4afbe62721fd2bf8497f363deecbfa6))
- **rpc**: Call close on container widget if needed
([`a13de45`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a13de45131309771c1438407f3733a8c0897d495))
- **rpc-base**: Deprecate widget_name in favor of object_name; closes #499
([`86647b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/86647b9b7e2fa111105ae483808883a624fa4cd6))
- **rpc_base**: Ensure message wait event is set after processing RPC response
([`4dc59aa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4dc59aa5e9b15e5ec40401e80e7965acd88e2fce))
- **rpc_base**: Timeout run_rpc 3s
([`8558b46`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8558b46114760a9434eaa827f81d5fd9d047112f))
- **rpc_register**: _lock and _skip_broad_cast moved to instance attributes
([`8d17f7e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8d17f7e32f81894294d7da472268e8d9eb3bb74b))
- **rpc_register**: Change add_rpc parameter type to BECConnector and add object_is_registered
method
([`82b8265`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/82b82659b7919b15d629375866302624b5b6e457))
- **rpc_register**: Lock changed to RLock
([`6c90ca3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6c90ca31078d97124a3ad535ffe83da138558d67))
- **rpc_server**: Broadcasted data check
([`c36852b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c36852b2ef762cdae3fde569bbd0d5f2f6f2725b))
- **rpc_server**: Enhance serialization logic for BECConnector objects and fix return types
([`125afc8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/125afc89073b4fc69a3f42650b3d4f6fa6ccaa47))
- **rpc_server**: Update _serialize_bec_connector to include wait parameter for registration check
([`d6fccd1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d6fccd10f5d600ea67cf7b2a5ebb42295d15cdfe))
- **RPCReference**: Setattr added
([`a2128ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a2128ad8d688995551c5e26974396fd0588b6804))
- **scan_control**: Restore scan parameters always regenerate the arg box, preventing infinite loop
([`1f2db92`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1f2db927f50f4f30d43ebe52e39118c7d79994d4))
- **scan_matadata**: Parent passing
([`4eaadd1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4eaadd1545885b111fce3f8cab527a77b8633ff3))
- **scatter_waveform,waveform**: Added QTimer to fetch the last data points after 500ms
([`e6795dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6795dd87ccd93cfd53e22cd94d71bffe1ef54dd))
- **serialization**: Add serialization for qpointf
([`3ddfeaa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3ddfeaa49fd4a7fdbff7cae47b90c25720f6dca0))
- **server**: Becdockarea type added
([`4a74891`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a74891184f112751258866b6bc9d800dbc5ed05))
- **server**: Remove window.hide() since widgets will be teared down on kill_server before siginit
signals is sent
([`58b0c7d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58b0c7ddc1d0b85b35e7e18434c0b83aac01a735))
- **server**: Turn_off_the_lights cleanup fixed for parent_id widgets
([`20a86ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/20a86ad325d36aa5aec73aeda7ff43ea9cc6c1f7))
- **setting_widget**: Added parent kwarg into all settings widgets in plotting framework
([`94c2e2d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/94c2e2db6518402207b2a1077bb16403a8e61cee))
- **side_panel**: Side panel menu can be initialized without a title
([`112eed6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/112eed694c0ef6eb80ec7a7cfdfbaacf732d5b9f))
- **toolbar**: Update action check handling logic for SwitchableToolBarAction
([`ac08bdf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac08bdfab2162ac8fd103e60779a76d36e9a3765))
- **type hints**: Add future import to prevent sphinx from crashing
([`aff5a51`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aff5a51f4c059ce21ec72cefc263f37df2491480))
- **waveform**: Dap curve flickering
([`b03d2ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b03d2eaeed4263846c470bc45eba9208ced2370b))
- **waveform**: Error where scan history is empty
([`288ea4d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/288ea4dbbde6d5f770c37f4daf377da9ec8fe729))
- **waveform**: Fix dap curve categorization logic
([`b91f1fe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b91f1fe4879e43e71a1be49ce5a206efbae19315))
- **waveform**: Legend is correctly updated when changed from curve dialog
([`c2d2c48`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c2d2c484cd1f133f45fe7147616c22c0b5fd5611))
- **waveform**: Signals for x device can be defined from gui
([`39164fe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/39164feb18f9e996d97814f58157892e8db816ae))
- **waveform, rpc_reference**: __getitem__ removed form waveform and rpc_reference
([`3a82c95`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3a82c95f60cb2b7f0b29a1ea5cdcbfa5bf602af8))
- **website-widget**: Add super().cleanup() in website widget
([`8fbd54c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8fbd54c3aa864623e42b39a1ebf92ba098ba437d))
- **widgets**: Becconnector resolves hierarchy including objectName, parent, parent_id upon init;
all widgets adjusted
([`a1bec75`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a1bec7511549277da231928d989b16ecad0eed1b))
### Build System
- Pyside6 capped to 6.9
([`9dabf2c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9dabf2c66c8023194964b9ad308e06197471f89f))
- **bec_lib**: Raised required version to 3.28.1
([`a5f1f47`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a5f1f4781ed9787148053d71e0d12fefe42e142a))
- **dependencies**: Update min bec_lib version to 3.29
([`eb0323b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/eb0323b989e96e89d2eb1ff7b648edb43f5fe198))
### Continuous Integration
- **e2e**: E2e tests are saving logs
([`d4106c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d4106c548e2373463a48268fd991ded7f554e3a6))
### Documentation
- Add docs on widget plugins
([`52a9f29`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/52a9f29bdcb20a9339a8970508bc0a93ba8bef5f))
- Add missing class doc strings for rpc-enabled widgets
([`cfc8272`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cfc8272ac288541d1e20c0840bd2ce6fa930897c))
- Better document logpanel code
([`d2c9075`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2c90757c21e040940a378325cad75c4d94470f9))
- Grammar improvement
([`1fe052e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1fe052e9da9e44bf9872db0d42218843a8e6d275))
- Remove BECFigure
([`75cc45d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/75cc45d767970e985c566fd4aeccd4394f48dfa3))
- Remove BECFigure from docs, fix wrong api for docs of plotting widgets
([`a1c859c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a1c859c7434357e6fb82f0912314c203fb73e890))
- Replaces instances of QtDesigner with BEC Designer for improved clarity
([`60852e2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/60852e228f80f0d2e74813f82bd30f1ba83ff154))
- Review quick_start
([`4acf5be`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4acf5befb1dbcc69e8cc7da70ebf5663b9ec15f2))
- Update docs for v2
([`25bd905`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/25bd905cef987a713e24aca178c04aef1ab59656))
- Update docs for various widgets
([`b6695b4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b6695b45d076dbf3896e94eedcf73d542022d764))
- Update quick_start
([`afc818b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/afc818bf7d17f42703c3cebafd2b292b8444647a))
- Updated docs for v2 ([#531](https://gitlab.psi.ch/bec/bec_widgets/-/merge_requests/531),
[`b4af2cc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b4af2cc77aa0013f4547cd98345b0c77abb7101b))
- **auto_updates**: Update documentation for auto updates functionality and add launcher image
([`6630ba1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6630ba1c421e566bf86ac38701a86eff624395d2))
- **lmfit**: Fix links
([`5e4965f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5e4965fe1f88d18fcc7e6875777ff3eb01ab08ec))
- **plot_base**: Update docstrings for properties and setters
([`b085ef6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b085ef6e730d529149bcb696b1ad4cd9c5220a83))
- **position-indicator**: Update docs for positioner indicator
([`2f0d213`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2f0d213e32fd662bfffd4df73b9281fa30cef6e3))
### Features
- Add loader/helper for widget plugins
([`ca2bb4f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca2bb4f9b42ebaac2fc544d3da36267d93e9903d))
- Add rpc broadcast
([`2ba9b4c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2ba9b4cb236a2182261dfb88398d5ece733ba393))
- Add support for auto updates
([`2511056`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2511056557daf0b5dd78d3e85ac4befb8bf8c316))
- Delete bec_app
([`8e64b65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e64b65c2d3b8a8f3c6e5376e694369b41733da4))
- Deprecated and delete alignment_1d gui
([`27ea92d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/27ea92d120cc8ef01fff10341ce0954b4f7fed5d))
- Namespace update for gui, dock_area and docks.
([`ac3c5a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac3c5a38e449c2c3e4a1c61d5f9a59acfbf0cab5))
- **auto_update**: Add GUI highlight management for auto updates status
([`5f272a6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f272a66a4d4f65273d7b2a6709336cd3582d695))
- **auto_updates**: Enforce rpc widget class for subclasses of auto updates
([`778230b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/778230b5edf5d24df8a10c78c90ea065510e8344))
- **image**: New Image widget based on new PlotBase
([`cb39ff3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cb39ff3fbde99f4e4bed49dee8a5e5987d257b23))
- **launch_window**: Add custom UI file launching functionality and UI tile
([`3089ca1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3089ca15ec4a8c110d11c57aff2da42f4af5bd08))
- **launch_window**: Add user access permissions
([`8efa93d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8efa93d2d2c5e6c28008e1bbde89e5cc8a01d139))
- **launch_window**: Enhance auto update functionality with selector and dynamic loading
([`2965323`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/29653239c5cf43313224cc5123d066fcba4b831b))
- **launcher**: Add option for launching with auto updates
([`20a1c5d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/20a1c5ddb3cd0763ce69bba5a893f54c56678706))
- **main_window**: Add launcher menu and functionality to show launcher
([`55baa84`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/55baa84eb6723b30b407092bc36f826b826cc934))
- **motor_map**: New MotorMap widget based on PlotBase
([`fec26d7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fec26d793e14965a719a4d038838418b9a7603bb))
- **multi_waveform**: Multi-waveform widget based on new PlotBase
([`77f9616`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/77f96160ab348c1a65ceb55986ea4ea75f8be04a))
- **plugin_utils**: Add functionality to retrieve auto update classes from plugins
([`c434af9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c434af9b92d68d08da87112ef424738e5e42ae6e))
- **positioner_box**: Add units QLabel to device UI components and update visibility logic
([`f653fc5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f653fc5f7ebf8ad5297facd739a8a49ea0a06c95))
- **scatter_waveform**: Scatter waveform widget based on new Plotbase
([`95fcf01`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/95fcf016c32c52330acfd5900a3996c99c4ee01f))
- **server,launcher**: Rpc server separated with the launcher window introduced
([`5f27a90`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f27a9098903ffd8ec27c1b45565f1c113892cca))
- **slot**: Add 'verify_sender' argument to SafeSlot for sender verification
([`8eef425`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8eef4253b0507f60f50c06ed48b59a1b19b29644))
- **ui_launch_window**: Add UILaunchWindow class
([`45cd82e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/45cd82e6354c72e1e35cd6366aa7aad93f8b12ca))
- **waveform**: New Waveform widget based on NextGen PlotBase
([`4bec181`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4bec181f3aff34d9de7d3f9ec012b641c125a661))
- **widget_io**: Added handler for Sliders
([`1a0097e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1a0097e02728b6470217d3a574260f376776d81f))
### Refactoring
- Add fallback to 'index' plotting in case of missmatch in length
([`515d7ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/515d7ad05584086a8e8ac626b476d629e27aacf3))
- Add pragma no cover to various TYPE_CHECKING
([`f88dfc8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f88dfc8f1bbc0819736a4f32bf21682366fd3437))
- Add support to plot against x_data
([`0e276d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0e276d4c09cddb459688aecf28684a963d8f6613))
- Add template for debugging the cli generator
([`f89e74b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f89e74b199d007cf47f355a1c5e1f582daeea90a))
- Autoupdate disabled
([`4e29291`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e29291b3a0891657a8d2011bcaf1d6e65de125a))
- Cleanup MR
([`0b00cd2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0b00cd24fd43dbc87c81f7dbfae816343f7da4c4))
- Cleanup rpc reference tracking, fix appquit, fix namespace updates edge cases
([`7ba93ce`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7ba93ce934cc644ad6340f141a6a0888bd1d3d98))
- Cleanup, fix tests and _top_level dict/windows
([`5872253`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58722531232b2290f9fd974bae24877c9d5451f4))
- Fix cleanup bug for BECConnector items, renamed _registry_state to _server_registry
([`be83c7d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be83c7d5f4bc04d110734b491727dc60d8dd61ef))
- Fix cleanup for various widgets, including RoundedFrame
([`d05179a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d05179a519d6419c9631ffdf4fa6aa262966c2ed))
- Improve plotting behaviour from history
([`ed2d958`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ed2d958de62223cd796c869b6c8b9b75170e66f5))
- Rearrange base of metadata forms for generic use
([`d04770f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d04770fe913474ec9d4e06b056c85e720d1470c4))
- Set downsampling to auto=True, method 'peak', activate clipToView for (Async)-Curves and fix
ViewAll hook from pg.view_box menu
([`25820a1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/25820a1cdec2cff99ab0d6085aece0e3e7dd9092))
- Tidy client generation and add options
([`b492591`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b4925918f7acf31e40971814639be8a6c55d46df))
- **assets**: New icon for ui loader
([`e5b5322`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5b532274ede281456a14b02a99855302603490a))
- **auto_update**: Auto_update changed to be BECMainWindow; removed auto update logic from
BECDockArea
([`56c2827`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56c282714037f733bcfd8a659f34baadcd1aa223))
- **auto_updates**: Move cleanup method from user section to internal section
([`ac9224e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac9224e5f2d3edcb5b1cc1cbc1a8583f81d0b912))
- **bec_connector**: Replace pyqtSlot with SafeSlot for consistency
([`9d6d0b4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9d6d0b406a812e08ca8417415b5def98b40bdf92))
- **bec_figure**: Becfigure removed
([`f76d931`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f76d9319bd13bb52b1ae2524c1c5e44a167cc330))
- **client_utils**: Remove unused auto update attributes from BECGuiClient
([`b7795b4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b7795b4d0ae21641bead0f1f1541f920ae95702a))
- **image_widget**: Old BECImageWidget removed
([`de10609`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/de10609b3c714b80a14bf6940e86763d0779402b))
- **launch_window**: Remove cleanup method
([`9a940bb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9a940bb8d58f37d1fc24ce4fdb38282d02349efb))
- **launcher,main_window**: Launcher window moved to inherit from BECMainWindow
([`99383b7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99383b77150ca7c74c19c899a0e6a7879b770376))
- **motor_map_widget**: Becmotormapwidget removed
([`f878e87`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f878e87ad545e0fe68292030d9f06dee693e0da2))
- **multi_waveform_widget**: Becmultiwaveformwidget removed
([`7c31bbd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7c31bbd9c2e0230e54f4dca0f1e5c4d2cd6e7674))
- **plots**: Plot_next_gen module renamed to plots
([`9fb9a1c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9fb9a1cfd2a94efd5e2a9fcbaa05d65c7b7105ee))
- **plots**: Waveform and image rpc api review
([`a3de1f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a3de1f0a31dfb9048493fb61983167960577fb97))
- **rpc_reference**: Refactor rpc reference tracking
([`bd5e251`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bd5e251ee9396633f419732e43411821726250aa))
- **rpc_server**: Add type hint for _get_becwidget_ancestor method parameter; minor cleanup of
imports
([`cb91ebc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cb91ebc0c34ae29b6b293996199f4624d36a3cc0))
- **rpc_server**: Add type hints and docstrings for heartbeat and registry update methods
([`08168f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/08168f28d3c5375b9ace9df0d7aa31e33adb97e9))
- **rpc_server**: Cli_server renamed to rpc_server
([`6082e7a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6082e7a6907c2fe15e4e5ebca857fbf8f222d192))
- **tests**: Create dummy scan item moved to client_mocks.py
([`0dd9617`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0dd9617e6e5ea756edc344c324451480a62bdae2))
- **ui_loader**: Remove unnecessary parent_id handling
([`d60cf6c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d60cf6c843ecc135a6065d1e913f9f6abb1a483d))
- **ui_loader**: Remove unused import
([`a6ce312`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a6ce312f7c60c2babcc37127f7c69d54c1b32573))
- **utils**: Qt_utils moved to utils
([`be552d3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be552d3ece97e7f472c4534b4af8438b95c518aa))
- **waveform_widget**: Removed and replaced by Waveform
([`96cff49`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96cff49cd4453fa70d8802653d5afe62d71c6b2a))
### Testing
- Add function scoped rpc_widgets e2e test; closes #510
([`36dc174`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/36dc174bfedf212532658b84f8ab64971863d292))
- Add IPython client GUI object test module with tab completion
([`e3d0d55`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e3d0d5566c50f6a80ba861d4e3e0789f17785a46))
- Add tests for name creation of custom curves, and object name handling
([`99d7623`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99d76236cac63042f0d7d1db580dde8aa7cfd214))
- Disable test_bec_dock_rpc_e2e module, issue to fix this created #450
([`17f2dda`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17f2dda977025bc422e26289293d3fcbd224a6f6))
- Fix rpc widgets e2e test
([`113938e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/113938e71a6dacba37164069e2c795cc9db168d4))
- Fix tests for launcher close / hide behavior
([`23fee22`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/23fee22ef8f2c22f191dfc1da57b921484ede6cd))
- Fix tests for namespace updates
([`f3d3c94`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f3d3c9425d3ed619b978427cea782137beedfb59))
- Qapp must shutdown cli server before checking for leaked QTimer
([`d066051`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d06605122e5c2e225650b44ebfc047daa5aa6f55))
- **bec_connector**: Becconnector requires a QObject
([`23bdd95`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/23bdd95d8c6311d989cb3b807921e3fb2a3d62a0))
- **device_signal_input**: Fix init of device input widget
([`31c3b64`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/31c3b64d7b157e5e26d44e5288afabef343c5e13))
- **e2e**: E2e tests adjusted for new plotting framework
([`378398a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/378398a29b34e43f0cca0a49b08adfcb144e4777))
- **generate_cli**: Fix reference output
([`a8adb06`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a8adb064f5011e1708ee2dc0090326f533407260))
- **launch_window**: Add test for launching UI file that raises ValueError for QMainWindow
([`33a8a76`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/33a8a767f31a57bcfda624d503ed39e0e4578dcb))
- **launch_window**: Add unit tests for LaunchWindow initialization and custom UI file launching
([`d5e422c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d5e422c7fc0e169c35d7f206937e8c7902fbf123))
- **launch_window**: Tests for default and plugin auto updates
([`e10f5ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e10f5ec088c6937beb26ec468f510a209c7cc782))
- **plot_base**: Test for plot base re-enabled
([`b51d637`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b51d637c5ff3418420801cd9b457fc073fa98adc))
- **plot_indicators**: Tests adapted to not be dependent on BECWaveformWidget
([`360fe4c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/360fe4c9c3b5c3c1f26e97cb795aef8f4aba3b46))
- **setting_dialog**: Test that settings reject calls cleanup
([`8914f1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8914f1d50600cab588a6cbecb08d85bfd1a715a1))
- **unit_tests**: Unit tests adjusted to use a modern plotting framework instead of BECFigure
([`6ade934`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ade93435632fa66fb012d92f9b8b548d96e718f))
## v1.25.1 (2025-03-24)
### Bug Fixes

View File

@@ -0,0 +1,4 @@
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]

View File

@@ -1,199 +0,0 @@
""" This module contains the GUI for the 1D alignment application.
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
"""
import os
from typing import Optional
from bec_lib.device import Signal as BECSignal
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot as Slot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
PositionerGroup,
)
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
class Alignment1D:
"""Alignment GUI to perform 1D scans"""
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
"""Initialization
Args:
config: Configuration of the application.
client: BEC client object.
gui_id: GUI ID.
"""
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
QApplication.instance().aboutToQuit.connect(self.close)
self.dev = self.client.device_manager.devices
self._accent_colors = get_accent_colors()
self.ui_file = "alignment_1d.ui"
self.ui = None
self.progress_bar = None
self.waveform = None
self.init_ui()
def init_ui(self):
"""Initialise the UI from QT Designer file"""
current_path = os.path.dirname(__file__)
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
# Customize the plotting widget
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
self._customise_bec_waveform_widget()
# Setup comboboxes for motor and signal selection
# FIXME after changing the filtering in the combobox
self._setup_signal_combobox()
# Setup motor indicator
self._setup_motor_indicator()
# Setup progress bar
self._setup_progress_bar()
# Add actions buttons
self._customise_buttons()
# Hook scaninfo updates
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
def show(self):
return self.ui.show()
##############################
############ SLOTS ###########
##############################
@Slot(dict, dict)
def scan_status_callback(self, content: dict, _) -> None:
"""This slot allows to enable/disable the UI critical components when a scan is running"""
if content["status"] in ["open"]:
self.enable_ui(False)
elif content["status"] in ["aborted", "halted", "closed"]:
self.enable_ui(True)
@Slot(tuple)
def move_to_center(self, move_request: tuple) -> None:
"""Move the selected motor to the center"""
motor = self.ui.device_combobox.currentText()
if move_request[0] in ["center", "center1", "center2"]:
pos = move_request[1]
self.dev.get(motor).move(float(pos), relative=False)
@Slot()
def reset_progress_bar(self) -> None:
"""Reset the progress bar"""
self.progress_bar.set_value(0)
self.progress_bar.set_minimum(0)
@Slot(dict, dict)
def update_progress_bar(self, content: dict, _) -> None:
"""Hook to update the progress bar
Args:
content: Content of the scan progress message.
metadata: Metadata of the message.
"""
if content["max_value"] == 0:
self.progress_bar.set_value(0)
return
self.progress_bar.set_maximum(content["max_value"])
self.progress_bar.set_value(content["value"])
@Slot()
def clear_queue(self) -> None:
"""Clear the scan queue"""
self.queue.request_queue_reset()
##############################
######## END OF SLOTS ########
##############################
def enable_ui(self, enable: bool) -> None:
"""Enable or disable the UI components"""
# Enable/disable motor and signal selection
self.ui.device_combobox_2.setEnabled(enable)
# Enable/disable DAP selection
self.ui.dap_combo_box.setEnabled(enable)
# Enable/disable Scan Button
# self.ui.scan_button.setEnabled(enable)
# Disable move to buttons in LMFitDialog
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
def _customise_buttons(self) -> None:
"""Add action buttons for the Action Control.
In addition, we are adding a callback to also clear the queue to the stop button
to ensure that upon clicking the button, no scans from another client may be queued
which would be confusing without the queue widget.
"""
fit_dialog = self.ui.findChild(LMFitDialog)
fit_dialog.active_action_list = ["center", "center1", "center2"]
fit_dialog.move_action.connect(self.move_to_center)
stop_button = self.ui.findChild(StopButton)
stop_button.button.setText("Stop and Clear Queue")
stop_button.button.clicked.connect(self.clear_queue)
def _customise_bec_waveform_widget(self) -> None:
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
self.waveform.toolbar.clear()
def _setup_motor_indicator(self) -> None:
"""Setup the arrow item"""
self.waveform.waveform.tick_item.add_to_plot()
positioner_box = self.ui.findChild(PositionerGroup)
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
self.waveform.waveform.tick_item.set_position(0)
def _setup_signal_combobox(self) -> None:
"""Setup signal selection"""
# FIXME after changing the filtering in the combobox
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
self.ui.device_combobox_2.setCurrentText(signals[0])
self.ui.device_combobox_2.set_device_filter("Signal")
def _setup_progress_bar(self) -> None:
"""Setup progress bar"""
# FIXME once the BECScanProgressBar is implemented
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
self.progress_bar.set_value(0)
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
def close(self):
logger.info("Disconnecting", repr(self.bec_dispatcher))
self.bec_dispatcher.disconnect_all()
logger.info("Shutting down BEC Client", repr(self.client))
self.client.shutdown()
def main():
import sys
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
window = Alignment1D()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,615 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>mainWindow</class>
<widget class="QMainWindow" name="mainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1611</width>
<height>1019</height>
</rect>
</property>
<property name="windowTitle">
<string>Alignment tool</string>
</property>
<widget class="QWidget" name="widget">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QWidget" name="widget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="DarkModeButton" name="dark_mode_button"/>
</item>
<item>
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BECStatusBox" name="bec_status_box">
<property name="compact_view" stdset="0">
<bool>true</bool>
</property>
<property name="label" stdset="0">
<string>BEC Servers</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BECQueue" name="bec_queue">
<property name="compact_view" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QRadioButton" name="radioButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>SLS Light On</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QRadioButton" name="radioButton_3">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>BEAMLINE Checks</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="StopButton" name="stop_button">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>40</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>40</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="BECProgressBar" name="bec_progress_bar">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="ControlTab">
<attribute name="title">
<string>Alignment Control</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QWidget" name="widget_4" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="ScanControl" name="scan_control">
<property name="current_scan" stdset="0">
<string>line_scan</string>
</property>
<property name="hide_arg_box" stdset="0">
<bool>false</bool>
</property>
<property name="hide_scan_selection_combobox" stdset="0">
<bool>true</bool>
</property>
<property name="hide_add_remove_buttons" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="PositionerGroup" name="positioner_group"/>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="widget_3" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>4</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="widget_2" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="label_2">
<property name="font">
<font/>
</property>
<property name="text">
<string>Monitor</string>
</property>
</widget>
</item>
<item>
<widget class="DeviceComboBox" name="device_combobox_2"/>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="font">
<font/>
</property>
<property name="text">
<string>LMFit Model</string>
</property>
</widget>
</item>
<item>
<widget class="DapComboBox" name="dap_combo_box"/>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Enable ROI</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="toggle_switch">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>3</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Activate linear region select for LMFit</string>
</property>
<property name="layoutDirection">
<enum>Qt::LayoutDirection::LeftToRight</enum>
</property>
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_8">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="BECWaveformWidget" name="bec_waveform_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>600</width>
<height>450</height>
</size>
</property>
<property name="clear_curves_on_plot_update" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="LMFitDialog" name="lm_fit_dialog">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>190</height>
</size>
</property>
<property name="always_show_latest" stdset="0">
<bool>true</bool>
</property>
<property name="hide_curve_selection" stdset="0">
<bool>true</bool>
</property>
<property name="hide_summary" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Logbook</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="WebsiteWidget" name="website_widget">
<property name="url" stdset="0">
<string>https://scilog.psi.ch/login</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>DapComboBox</class>
<extends>QWidget</extends>
<header>dap_combo_box</header>
</customwidget>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWidget</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
<customwidget>
<class>DarkModeButton</class>
<extends>QWidget</extends>
<header>dark_mode_button</header>
</customwidget>
<customwidget>
<class>PositionerGroup</class>
<extends>QWidget</extends>
<header>positioner_group</header>
</customwidget>
<customwidget>
<class>BECWaveformWidget</class>
<extends>QWidget</extends>
<header>bec_waveform_widget</header>
</customwidget>
<customwidget>
<class>DeviceComboBox</class>
<extends>QComboBox</extends>
<header>device_combobox</header>
</customwidget>
<customwidget>
<class>LMFitDialog</class>
<extends>QWidget</extends>
<header>lm_fit_dialog</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>toggle_switch</sender>
<signal>enabled(bool)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>toogle_roi_select(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>1042</x>
<y>212</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>322</y>
</hint>
</hints>
</connection>
<connection>
<sender>bec_waveform_widget</sender>
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
<receiver>lm_fit_dialog</receiver>
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
<hints>
<hint type="sourcelabel">
<x>1099</x>
<y>258</y>
</hint>
<hint type="destinationlabel">
<x>1157</x>
<y>929</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combobox_2</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>plot(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>427</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combobox_2</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_y_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>909</x>
<y>215</y>
</hint>
</hints>
</connection>
<connection>
<sender>dap_combo_box</sender>
<signal>new_dap_config(QString,QString,QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>add_dap(QString,QString,QString)</slot>
<hints>
<hint type="sourcelabel">
<x>909</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>447</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>positioner_group</receiver>
<slot>set_positioners(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>230</x>
<y>306</y>
</hint>
<hint type="destinationlabel">
<x>187</x>
<y>926</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>set_x(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>187</x>
<y>356</y>
</hint>
<hint type="destinationlabel">
<x>972</x>
<y>509</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_x_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>187</x>
<y>356</y>
</hint>
<hint type="destinationlabel">
<x>794</x>
<y>202</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -1,84 +0,0 @@
"""
Launcher for BEC GUI Applications
Application must be located in bec_widgets/applications ;
in order for the launcher to find the application, it has to be put in
a subdirectory with the same name as the main Python module:
/bec_widgets/applications
├── alignment
│ └── alignment_1d
│ └── alignment_1d.py
├── other_app
└── other_app.py
The tree above would contain 2 applications, alignment_1d and other_app.
The Python module for the application must have `if __name__ == "__main__":`
in order for the launcher to execute it (it is run with `python -m`).
"""
import argparse
import os
import sys
MODULE_PATH = os.path.dirname(__file__)
def find_apps(base_dir: str) -> list[str]:
matching_modules = []
for root, dirs, files in os.walk(base_dir):
parent_dir = os.path.basename(root)
for file in files:
if file.endswith(".py") and file != "__init__.py":
file_name_without_ext = os.path.splitext(file)[0]
if file_name_without_ext == parent_dir:
rel_path = os.path.relpath(root, base_dir)
module_path = rel_path.replace(os.sep, ".")
module_name = f"{module_path}.{file_name_without_ext}"
matching_modules.append((file_name_without_ext, module_name))
return matching_modules
def main():
parser = argparse.ArgumentParser(description="BEC application launcher")
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
# Add a positional argument for the module, which acts as a fallback if -m is not provided
parser.add_argument(
"positional_module",
nargs="?", # This makes the positional argument optional
help="Positional argument that is treated as module if -m is not specified.",
)
args = parser.parse_args()
# If the -m/--module is not provided, fallback to the positional argument
module = args.module if args.module else args.positional_module
if module:
for app_name, app_module in find_apps(MODULE_PATH):
if module in (app_name, app_module):
print("Starting:", app_name)
python_executable = sys.executable
# Replace the current process with the new Python module
os.execvp(
python_executable,
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
)
print(f"Error: cannot find application {module}")
# display list of apps
print("Available applications:")
for app, _ in find_apps(MODULE_PATH):
print(f" - {app}")
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
return _dock_area
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
"""
Create a dock area with auto update enabled.
Args:
object_name(str): The name of the dock area.
Returns:
BECDockArea: The created dock area.
"""
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -0,0 +1,430 @@
from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QWidget,
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame):
open_signal = Signal()
def __init__(
self,
parent: QObject | None = None,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
):
super().__init__(parent=parent, orientation="vertical")
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
size = 100
circular_pixmap = QPixmap(size, size)
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setWordWrap(True)
self.main_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
if show_selector:
self.selector = QComboBox(self)
self.layout.addWidget(self.selector)
else:
self.selector = None
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.layout.addItem(self.spacer_bottom)
# Action button
self.action_button = QPushButton("Open")
self.action_button.setStyleSheet(
"""
QPushButton {
background-color: #007AFF;
border: none;
padding: 8px 16px;
color: white;
border-radius: 6px;
font-weight: bold;
}
QPushButton:hover {
background-color: #005BB5;
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
class LaunchWindow(BECMainWindow):
RPC = True
TILE_SIZE = (250, 300)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
# Main Widget
self.central_widget = QWidget(self)
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.tile_dock_area = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
)
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.tile_auto_update = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
show_selector=True,
)
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.tile_ui_file = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
)
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# Add tiles to the main layout
self.central_widget.layout.addWidget(self.tile_dock_area)
self.central_widget.layout.addWidget(self.tile_auto_update)
self.central_widget.layout.addWidget(self.tile_ui_file)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme()
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
)
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def launch(
self,
launch_script: str,
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
**kwargs,
) -> QWidget | None:
"""Launch the specified script. If the launch script creates a QWidget, it will be
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
as a separate window.
Args:
launch_script(str): The name of the script to be launched.
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
QWidget: The created dock area.
"""
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
if launch_script is None:
launch_script = "dock_area"
if not isinstance(launch_script, str):
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
if launch_script == "custom_ui_file":
ui_file = kwargs.pop("ui_file", None)
if not ui_file:
return None
return self._launch_custom_ui_file(ui_file)
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
result_widget = launch(name)
result_widget.resize(result_widget.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
result_widget.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
if geometry is not None:
result_widget.setGeometry(*geometry)
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
widget = root.find("widget")
if widget is None:
raise ValueError("No widget found in the UI file.")
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
else:
auto_update = "auto_updates"
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles:
tile.apply_theme(theme)
super().apply_theme(theme)
def _open_auto_update(self):
"""
Open the auto update window.
"""
if self.tile_auto_update.selector is None:
auto_update = None
else:
auto_update = self.tile_auto_update.selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""
Open a file dialog to select a custom UI file and launch it.
"""
ui_file, _ = QFileDialog.getOpenFileName(
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
)
self.launch("custom_ui_file", ui_file=ui_file)
@staticmethod
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Load all available auto updates from the plugin repository.
"""
try:
auto_updates = get_plugin_auto_updates()
logger.info(f"Available auto updates: {auto_updates.keys()}")
except Exception as exc:
logger.error(f"Failed to load auto updates: {exc}")
return {}
return auto_updates
def show_launcher(self):
"""
Show the launcher window.
"""
self.show()
def hide_launcher(self):
"""
Hide the launcher window.
"""
self.hide()
def showEvent(self, event):
super().showEvent(event)
self.setFixedSize(self.size())
def _launcher_is_last_widget(self, connections: dict) -> bool:
"""
Check if the launcher is the last widget in the application.
"""
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
if self._launcher_is_last_widget(connections):
self.show()
self.activateWindow()
self.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True) # type: ignore
return
self.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False) # type: ignore
def closeEvent(self, event):
"""
Close the launcher window.
"""
connections = self.register.list_all_connections()
if self._launcher_is_last_widget(connections):
event.accept()
return
event.ignore()
self.hide()
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -1 +0,0 @@
from .client import *

View File

@@ -1,169 +0,0 @@
# TODO autoupdate disabled
# from __future__ import annotations
#
# import threading
# from queue import Queue
# from typing import TYPE_CHECKING
#
# from pydantic import BaseModel
#
# if TYPE_CHECKING:
# from .client import BECDockArea, BECFigure
#
#
# class ScanInfo(BaseModel):
# scan_id: str
# scan_number: int
# scan_name: str
# scan_report_devices: list
# monitored_devices: list
# status: str
# model_config: dict = {"validate_assignment": True}
#
#
# class AutoUpdates:
# create_default_dock: bool = False
# enabled: bool = False
# dock_name: str = None
#
# def __init__(self, gui: BECDockArea):
# self.gui = gui
# self._default_dock = None
# self._default_fig = None
#
# def start_default_dock(self):
# """
# Create a default dock for the auto updates.
# """
# self.dock_name = "default_figure"
# self._default_dock = self.gui.new(self.dock_name)
# self._default_dock.new("BECFigure")
# self._default_fig = self._default_dock.elements_list[0]
#
# @staticmethod
# def get_scan_info(msg) -> ScanInfo:
# """
# Update the script with the given data.
# """
# info = msg.info
# status = msg.status
# scan_id = msg.scan_id
# scan_number = info.get("scan_number", 0)
# scan_name = info.get("scan_name", "Unknown")
# scan_report_devices = info.get("scan_report_devices", [])
# monitored_devices = info.get("readout_priority", {}).get("monitored", [])
# monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
# return ScanInfo(
# scan_id=scan_id,
# scan_number=scan_number,
# scan_name=scan_name,
# scan_report_devices=scan_report_devices,
# monitored_devices=monitored_devices,
# status=status,
# )
#
# def get_default_figure(self) -> BECFigure | None:
# """
# Get the default figure from the GUI.
# """
# return self._default_fig
#
# def do_update(self, msg):
# """
# Run the update function if enabled.
# """
# if not self.enabled:
# return
# if msg.status != "open":
# return
# info = self.get_scan_info(msg)
# return self.handler(info)
#
# def get_selected_device(self, monitored_devices, selected_device):
# """
# Get the selected device for the plot. If no device is selected, the first
# device in the monitored devices list is selected.
# """
# if selected_device:
# return selected_device
# if len(monitored_devices) > 0:
# sel_device = monitored_devices[0]
# return sel_device
# return None
#
# def handler(self, info: ScanInfo) -> None:
# """
# Default update function.
# """
# if info.scan_name == "line_scan" and info.scan_report_devices:
# return self.simple_line_scan(info)
# if info.scan_name == "grid_scan" and info.scan_report_devices:
# return self.simple_grid_scan(info)
# if info.scan_report_devices:
# return self.best_effort(info)
#
# def simple_line_scan(self, info: ScanInfo) -> None:
# """
# Simple line scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# selected_device = yield self.gui.selected_device
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
# if not dev_y:
# return
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# label=f"Scan {info.scan_number} - {dev_y}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )
#
# def simple_grid_scan(self, info: ScanInfo) -> None:
# """
# Simple grid scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# dev_y = info.scan_report_devices[1]
# selected_device = yield self.gui.selected_device
# dev_z = self.get_selected_device(info.monitored_devices, selected_device)
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# z_name=dev_z,
# label=f"Scan {info.scan_number} - {dev_z}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )
#
# def best_effort(self, info: ScanInfo) -> None:
# """
# Best effort scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# selected_device = yield self.gui.selected_device
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
# if not dev_y:
# return
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# label=f"Scan {info.scan_number} - {dev_y}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )

View File

@@ -1,53 +1,90 @@
# This file was automatically generated by generate_cli.py
# type: ignore
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
logger = bec_logger.logger
# pylint: skip-file
class Widgets(str, enum.Enum):
"""
Enum for the available widgets.
"""
class _WidgetsEnumType(str, enum.Enum):
"""Enum for the available widgets, to be generated programatically"""
AbortButton = "AbortButton"
BECColorMapWidget = "BECColorMapWidget"
BECDockArea = "BECDockArea"
BECProgressBar = "BECProgressBar"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
DapComboBox = "DapComboBox"
DarkModeButton = "DarkModeButton"
DeviceBrowser = "DeviceBrowser"
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
Image = "Image"
LMFitDialog = "LMFitDialog"
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
MotorMap = "MotorMap"
MultiWaveform = "MultiWaveform"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
PositionerControlLine = "PositionerControlLine"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
ScatterWaveform = "ScatterWaveform"
SignalComboBox = "SignalComboBox"
SignalLineEdit = "SignalLineEdit"
StopButton = "StopButton"
TextBox = "TextBox"
VSCodeEditor = "VSCodeEditor"
Waveform = "Waveform"
WebsiteWidget = "WebsiteWidget"
...
_Widgets = {
"AbortButton": "AbortButton",
"BECDockArea": "BECDockArea",
"BECProgressBar": "BECProgressBar",
"BECQueue": "BECQueue",
"BECStatusBox": "BECStatusBox",
"DapComboBox": "DapComboBox",
"DarkModeButton": "DarkModeButton",
"DeviceBrowser": "DeviceBrowser",
"DeviceComboBox": "DeviceComboBox",
"DeviceLineEdit": "DeviceLineEdit",
"Image": "Image",
"LogPanel": "LogPanel",
"Minesweeper": "Minesweeper",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PositionIndicator": "PositionIndicator",
"PositionerBox": "PositionerBox",
"PositionerBox2D": "PositionerBox2D",
"PositionerControlLine": "PositionerControlLine",
"ResetButton": "ResetButton",
"ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar",
"ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform",
"StopButton": "StopButton",
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
"WebsiteWidget": "WebsiteWidget",
}
try:
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
)
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class
except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
class AbortButton(RPCBase):
@@ -60,12 +97,39 @@ class AbortButton(RPCBase):
"""
class BECColorMapWidget(RPCBase):
class AutoUpdates(RPCBase):
@property
@rpc_call
def colormap(self):
def enabled(self) -> "bool":
"""
Get the current colormap name.
Get the enabled status of the auto updates.
"""
@enabled.setter
@rpc_call
def enabled(self) -> "bool":
"""
Get the enabled status of the auto updates.
"""
@property
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
@selected_device.setter
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
@@ -201,6 +265,8 @@ class BECDock(RPCBase):
class BECDockArea(RPCBase):
"""Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout."""
@property
@rpc_call
def _rpc_id(self) -> "str":
@@ -307,7 +373,8 @@ class BECDockArea(RPCBase):
@rpc_call
def remove(self) -> "None":
"""
Remove the dock area.
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
@rpc_call
@@ -328,13 +395,6 @@ class BECDockArea(RPCBase):
Return all floating docks to the dock area.
"""
@property
@rpc_call
def selected_device(self) -> "str":
"""
None
"""
@rpc_call
def save_state(self) -> "dict":
"""
@@ -358,14 +418,6 @@ class BECDockArea(RPCBase):
"""
class BECMainWindow(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
@@ -418,6 +470,12 @@ class BECProgressBar(RPCBase):
>>> progressbar.label_template = "$value / $percentage %"
"""
@rpc_call
def _get_label(self) -> str:
"""
Return the label text. mostly used for testing rpc.
"""
class BECQueue(RPCBase):
"""Widget to display the BEC queue."""
@@ -432,6 +490,12 @@ class BECQueue(RPCBase):
class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services."""
@rpc_call
def get_server_state(self) -> "str":
"""
Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server
"""
@rpc_call
def remove(self):
"""
@@ -463,6 +527,15 @@ class Curve(RPCBase):
dict: The configuration of the widget.
"""
@rpc_call
def _get_displayed_data(self) -> "tuple[np.ndarray, np.ndarray]":
"""
Get the displayed data of the curve.
Returns:
tuple[np.ndarray, np.ndarray]: The x and y data of the curve.
"""
@rpc_call
def set(self, **kwargs):
"""
@@ -558,7 +631,7 @@ class Curve(RPCBase):
"""
@rpc_call
def get_data(self) -> "tuple[np.ndarray, np.ndarray]":
def get_data(self) -> "tuple[np.ndarray | None, np.ndarray | None]":
"""
Get the data of the curve.
Returns:
@@ -627,14 +700,15 @@ class DapComboBox(RPCBase):
class DarkModeButton(RPCBase):
@rpc_call
def toggle_dark_mode(self) -> "None":
def remove(self):
"""
Toggle the dark mode state. This will change the theme of the entire
application to dark or light mode.
Cleanup the BECConnector
"""
class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
@rpc_call
def remove(self):
"""
@@ -672,17 +746,9 @@ class DeviceLineEdit(RPCBase):
"""
class DeviceSignalInputBase(RPCBase):
"""Mixin base class for device signal input widgets."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class Image(RPCBase):
"""Image widget for displaying 2D data."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -898,48 +964,6 @@ class Image(RPCBase):
Set auto range for the y-axis.
"""
@property
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@x_log.setter
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@y_log.setter
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@legend_label_size.setter
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -1134,16 +1158,16 @@ class Image(RPCBase):
@property
@rpc_call
def rotation(self) -> "int":
def num_rotation_90(self) -> "int":
"""
The number of 90° rotations to apply.
The number of 90° rotations to apply counterclockwise.
"""
@rotation.setter
@num_rotation_90.setter
@rpc_call
def rotation(self) -> "int":
def num_rotation_90(self) -> "int":
"""
The number of 90° rotations to apply.
The number of 90° rotations to apply counterclockwise.
"""
@property
@@ -1306,14 +1330,14 @@ class ImageItem(RPCBase):
@property
@rpc_call
def rotation(self) -> "Optional[int]":
def num_rotation_90(self) -> "Optional[int]":
"""
Get or set the number of 90° rotations to apply.
"""
@rotation.setter
@num_rotation_90.setter
@rpc_call
def rotation(self) -> "Optional[int]":
def num_rotation_90(self) -> "Optional[int]":
"""
Get or set the number of 90° rotations to apply.
"""
@@ -1341,16 +1365,6 @@ class ImageItem(RPCBase):
"""
class LMFitDialog(RPCBase):
"""Dialog for displaying the fit summary and params for LMFit DAP processes"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class LogPanel(RPCBase):
"""Displays a log panel"""
@@ -1377,6 +1391,8 @@ class Minesweeper(RPCBase): ...
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -1766,6 +1782,8 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -2150,7 +2168,7 @@ class MultiWaveform(RPCBase):
"""
@rpc_call
def plot(self, monitor: "str", color_palette: "str | None" = "magma"):
def plot(self, monitor: "str", color_palette: "str | None" = "plasma"):
"""
Create a plot for the given monitor.
Args:
@@ -2175,10 +2193,15 @@ class MultiWaveform(RPCBase):
class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits."""
@rpc_call
def set_value(self, position: float):
"""
None
Set the position of the indicator
Args:
position: The new position of the indicator
"""
@rpc_call
@@ -2198,6 +2221,13 @@ class PositionIndicator(RPCBase):
Property to determine the orientation of the position indicator
"""
@vertical.setter
@rpc_call
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
@property
@rpc_call
def indicator_width(self):
@@ -2205,6 +2235,13 @@ class PositionIndicator(RPCBase):
Property to get the width of the indicator
"""
@indicator_width.setter
@rpc_call
def indicator_width(self):
"""
Property to get the width of the indicator
"""
@property
@rpc_call
def rounded_corners(self):
@@ -2212,6 +2249,13 @@ class PositionIndicator(RPCBase):
Property to get the rounded corners of the position indicator
"""
@rounded_corners.setter
@rpc_call
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form"""
@@ -2248,16 +2292,6 @@ class PositionerBox2D(RPCBase):
"""
class PositionerBoxBase(RPCBase):
"""Contains some core logic for positioner box widgets"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class PositionerControlLine(RPCBase):
"""A widget that controls a single device."""
@@ -2404,6 +2438,8 @@ class Ring(RPCBase):
class RingProgressBar(RPCBase):
"""Show the progress of devices, scans or custom values in the form of ring progress bars."""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
@@ -2429,9 +2465,9 @@ class RingProgressBar(RPCBase):
@property
@rpc_call
def rings(self):
def rings(self) -> "list[Ring]":
"""
None
Returns a list of all rings in the progress bar.
"""
@rpc_call
@@ -2583,15 +2619,7 @@ class RingProgressBar(RPCBase):
class ScanControl(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class ScanMetadata(RPCBase):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the"""
"""Widget to submit new scans to the queue."""
@rpc_call
def remove(self):
@@ -2913,7 +2941,7 @@ class ScatterWaveform(RPCBase):
x_entry: "None | str" = None,
y_entry: "None | str" = None,
z_entry: "None | str" = None,
color_map: "str | None" = "magma",
color_map: "str | None" = "plasma",
label: "str | None" = None,
validate_bec: "bool" = True,
) -> "ScatterCurve":
@@ -2953,26 +2981,6 @@ class ScatterWaveform(RPCBase):
"""
class SignalComboBox(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class SignalLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class StopButton(RPCBase):
"""A button that stops the current scan."""
@@ -3005,6 +3013,14 @@ class TextBox(RPCBase):
"""
class UILaunchWindow(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class VSCodeEditor(RPCBase):
"""A widget to display the VSCode editor."""
@@ -3012,6 +3028,8 @@ class VSCodeEditor(RPCBase):
class Waveform(RPCBase):
"""Widget for plotting waveforms."""
@property
@rpc_call
def _config_dict(self) -> "dict":
@@ -3293,12 +3311,6 @@ class Waveform(RPCBase):
The font size of the legend font.
"""
@rpc_call
def __getitem__(self, key: "int | str"):
"""
None
"""
@property
@rpc_call
def curves(self) -> "list[Curve]":
@@ -3323,6 +3335,20 @@ class Waveform(RPCBase):
None
"""
@property
@rpc_call
def x_entry(self) -> "str | None":
"""
The x signal name.
"""
@x_entry.setter
@rpc_call
def x_entry(self) -> "str | None":
"""
The x signal name.
"""
@property
@rpc_call
def color_palette(self) -> "str":

View File

@@ -10,25 +10,31 @@ import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.redis_connector import StreamMessage
from bec_lib.messages import GUIRegistryStateMessage
else:
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
logger = bec_logger.logger
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
str | bool | dict,
]
# pylint: disable=redefined-outer-scope
@@ -67,7 +73,11 @@ def _get_output(process, logger) -> None:
def _start_plot_process(
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
gui_id: str,
gui_class_id: str,
config: dict | str,
gui_class: str = "dock_area",
logger=None, # FIXME change gui_class back to "launcher" later
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
"""
Start the plot in a new process.
@@ -82,7 +92,7 @@ def _start_plot_process(
"--id",
gui_id,
"--gui_class",
gui_class.__name__,
gui_class,
"--gui_class_id",
gui_class_id,
"--hide",
@@ -195,25 +205,28 @@ class BECGuiClient(RPCBase):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._lock = Lock()
self._default_dock_name = "bec"
self._auto_updates_enabled = True
self._auto_updates = None
self._anchor_widget = "launcher"
self._killed = False
self._top_level: dict[str, client.BECDockArea] = {}
self._top_level: dict[str, RPCReference] = {}
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self._exposed_widgets = []
self._server_registry = {}
self._ipython_registry = {}
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
####################
#### Client API ####
####################
@property
def launcher(self) -> RPCBase:
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
@@ -221,21 +234,25 @@ class BECGuiClient(RPCBase):
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
self._gui_id = gui_id
# Get the registry state
msgs = self._client.connector.xread(
MessageEndpoints.gui_registry_state(self._gui_id), count=1
)
if msgs:
self._handle_registry_update(msgs[0])
# reset the namespace
self._update_dynamic_namespace({})
self._server_registry = {}
self._top_level = {}
self._ipython_registry = {}
# Register the new callback
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
@property
def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return self._top_level
return {widget.object_name: widget for widget in self._top_level.values()}
@property
def window_list(self) -> list:
@@ -261,6 +278,8 @@ class BECGuiClient(RPCBase):
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
**kwargs,
) -> client.BECDockArea:
"""Create a new top-level dock area.
@@ -275,14 +294,12 @@ class BECGuiClient(RPCBase):
self.start(wait=True)
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
@@ -352,11 +369,13 @@ class BECGuiClient(RPCBase):
# Wait for 'bec' gui to be registered, this may take some time
# After 60s timeout. Should this raise an exception on timeout?
while time.time() < time.time() + timeout:
if len(list(self._server_registry.keys())) == 0:
if len(list(self._server_registry.keys())) < 2 or not hasattr(
self, self._anchor_widget
):
time.sleep(0.1)
else:
break
self._do_show_all()
self._gui_started_event.set()
def _start_server(self, wait: bool = False) -> None:
@@ -369,8 +388,7 @@ class BECGuiClient(RPCBase):
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
self.__class__,
gui_class_id=self._default_dock_name,
gui_class_id="bec",
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
@@ -380,7 +398,7 @@ class BECGuiClient(RPCBase):
if callable(callback):
callback()
finally:
threading.current_thread().cancel()
threading.current_thread().cancel() # type: ignore
self._gui_started_timer = RepeatTimer(
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
@@ -390,25 +408,27 @@ class BECGuiClient(RPCBase):
if wait:
self._gui_started_event.wait()
def _dump(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
return rpc_client._run_rpc("_dump")
def _start(self, wait: bool = False) -> None:
self._killed = False
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
)
return self._start_server(wait=wait)
def _handle_registry_update(self, msg: StreamMessage) -> None:
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self._server_registry = msg["data"].state
self._update_dynamic_namespace()
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -419,124 +439,72 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
if not self._killed:
for window in self._top_level.values():
window.hide()
def _update_dynamic_namespace(self):
"""Update the dynamic name space"""
# Clear the top level
self._top_level.clear()
# First we update the name space based on the new registry state
self._add_registry_to_namespace()
# Then we clear the ipython registry from old objects
self._cleanup_ipython_registry()
def _update_dynamic_namespace(self, server_registry: dict):
"""
Update the dynamic name space with the given server registry.
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
def _cleanup_ipython_registry(self):
"""Cleanup the ipython registry"""
names_in_registry = list(self._ipython_registry.keys())
names_in_server_state = list(self._server_registry.keys())
remove_ids = list(set(names_in_registry) - set(names_in_server_state))
for widget_id in remove_ids:
self._ipython_registry.pop(widget_id)
self._cleanup_rpc_references_on_rpc_base(remove_ids)
# Clear the exposed widgets
self._exposed_widgets.clear() # No longer needed I think
Args:
server_registry (dict): The server registry
"""
top_level_widgets: dict[str, RPCReference] = {}
for gui_id, state in server_registry.items():
widget = self._add_widget(state, self)
if widget is None:
# ignore widgets that are not supported
continue
# get all top-level widgets. These are widgets that have no parent
if not state["config"].get("parent_id"):
top_level_widgets[gui_id] = widget
def _cleanup_rpc_references_on_rpc_base(self, remove_ids: list[str]) -> None:
"""Cleanup the rpc references on the RPCBase object"""
if not remove_ids:
return
for widget in self._ipython_registry.values():
to_delete = []
for attr_name, gui_id in widget._rpc_references.items():
if gui_id in remove_ids:
to_delete.append(attr_name)
for attr_name in to_delete:
if hasattr(widget, attr_name):
delattr(widget, attr_name)
if attr_name.startswith("elements."):
delattr(widget.elements, attr_name.split(".")[1])
widget._rpc_references.pop(attr_name)
remove_from_registry = []
for gui_id, widget in self._ipython_registry.items():
if gui_id not in server_registry:
remove_from_registry.append(gui_id)
for gui_id in remove_from_registry:
self._ipython_registry.pop(gui_id)
def _set_dynamic_attributes(self, obj: object, name: str, value: Any) -> None:
"""Add an object to the namespace"""
setattr(obj, name, value)
def _update_rpc_references(self, widget: RPCBase, name: str, gui_id: str) -> None:
"""Update the RPC references"""
widget._rpc_references[name] = gui_id
def _add_registry_to_namespace(self) -> None:
"""Add registry to namespace"""
# Add dock areas
dock_area_states = [
state
for state in self._server_registry.values()
if state["widget_class"] == "BECDockArea"
removed_widgets = [
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
]
for state in dock_area_states:
dock_area_ref = self._add_widget(state, self)
dock_area = self._ipython_registry.get(dock_area_ref._gui_id)
if not hasattr(dock_area, "elements"):
self._set_dynamic_attributes(dock_area, "elements", WidgetNameSpace())
self._set_dynamic_attributes(self, dock_area.widget_name, dock_area_ref)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(self, dock_area.widget_name, dock_area_ref._gui_id)
# Add dock_area to the top level
self._top_level[dock_area_ref.widget_name] = dock_area_ref
self._exposed_widgets.append(dock_area_ref._gui_id)
# Add docks
dock_states = [
state
for state in self._server_registry.values()
if state["config"].get("parent_id", "") == dock_area_ref._gui_id
]
for state in dock_states:
dock_ref = self._add_widget(state, dock_area)
dock = self._ipython_registry.get(dock_ref._gui_id)
self._set_dynamic_attributes(dock_area, dock_ref.widget_name, dock_ref)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(dock_area, dock_ref.widget_name, dock_ref._gui_id)
# Keep track of exposed docks
self._exposed_widgets.append(dock_ref._gui_id)
for widget_name in removed_widgets:
# the check is not strictly necessary, but better safe
# than sorry; who knows what the user has done
if hasattr(self, widget_name):
delattr(self, widget_name)
# Add widgets
widget_states = [
state
for state in self._server_registry.values()
if state["config"].get("parent_id", "") == dock_ref._gui_id
]
for state in widget_states:
widget_ref = self._add_widget(state, dock)
self._set_dynamic_attributes(dock, widget_ref.widget_name, widget_ref)
self._set_dynamic_attributes(
dock_area.elements, widget_ref.widget_name, widget_ref
)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(
dock_area, f"elements.{widget_ref.widget_name}", widget_ref._gui_id
)
self._update_rpc_references(dock, widget_ref.widget_name, widget_ref._gui_id)
# Keep track of exposed widgets
self._exposed_widgets.append(widget_ref._gui_id)
for gui_id, widget_ref in top_level_widgets.items():
setattr(self, widget_ref.object_name, widget_ref)
def _add_widget(self, state: dict, parent: object) -> RPCReference:
self._top_level = top_level_widgets
for widget in self._ipython_registry.values():
widget._refresh_references()
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
"""Add a widget to the namespace
Args:
state (dict): The state of the widget from the _server_registry.
parent (object): The parent object.
"""
name = state["name"]
object_name = state["object_name"]
gui_id = state["gui_id"]
widget_class = getattr(client, state["widget_class"])
if state["widget_class"] in IGNORE_WIDGETS:
return
widget_class = getattr(client, state["widget_class"], None)
if widget_class is None:
return
obj = self._ipython_registry.get(gui_id)
if obj is None:
widget = widget_class(gui_id=gui_id, name=name, parent=parent)
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
self._ipython_registry[gui_id] = widget
else:
widget = obj

View File

@@ -2,17 +2,22 @@
from __future__ import annotations
import argparse
import importlib
import inspect
import os
import sys
from pathlib import Path
import black
import isort
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger
if sys.version_info >= (3, 11):
from typing import get_overloads
else:
@@ -29,13 +34,29 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
def __init__(self, base=False):
self._base = base
base_imports = (
"""import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base
else "\n"
)
self.header = f"""# This file was automatically generated by generate_cli.py
# type: ignore \n
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
# pylint: skip-file"""
@@ -62,6 +83,7 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
logger.debug(f"generating RPC client class for {cls.__name__}")
self.content += "\n\n"
self.generate_content_for_class(cls)
@@ -69,14 +91,50 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
"""
Write the client enum to the content.
"""
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
...
"""
self.content += """
class Widgets(str, enum.Enum):
\"\"\"
Enum for the available widgets.
\"\"\"
_Widgets = {
"""
for cls in published_classes:
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
self.content += """}
"""
if self._base:
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class
except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
"""
def generate_content_for_class(self, cls):
"""
@@ -165,18 +223,18 @@ class {class_name}(RPCBase):"""
# Combine header and content, then format with black
full_content = self.header + "\n" + self.content
try:
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
except black.NothingChanged:
formatted_content = full_content
isort.Config(
config = isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=True,
include_trailing_comma=False,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content)
formatted_content = isort.code(formatted_content, config=config)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@@ -188,41 +246,78 @@ def main():
"""
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
parser.add_argument(
"--target",
action="store",
type=str,
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
)
args = parser.parse_args()
if args.target is None:
logger.error(
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
)
return
if args.core:
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
logger.info(f"BEC Widget code generation tool started with args: {args}")
rpc_classes = get_custom_classes("bec_widgets")
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
try:
module = importlib.import_module(module_name)
assert module.__file__ is not None
module_file = Path(module.__file__)
module_dir = module_file.parent if module_file.is_file() else module_file
except Exception as e:
logger.error(f"Failed to load module {module_name} for code generation: {e}")
return
for cls in rpc_classes.plugins:
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
client_path = module_dir / client_subdir / "client.py"
# if the class directory already has a register, plugin and pyproject file, skip
if os.path.exists(
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
):
continue
plugin.run()
rpc_classes = get_custom_classes(module_name)
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
generator = ClientGenerator(base=module_name == "bec_widgets")
logger.info(f"Generating client file at {client_path}")
generator.generate_client(rpc_classes)
generator.write(str(client_path))
if module_name != "bec_widgets":
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
logger.info(
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
)
else:
non_overwrite_classes = []
for cls in rpc_classes.plugins:
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
if cls.__name__ in non_overwrite_classes:
logger.error(
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
)
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
def _exists(file: str):
return os.path.exists(os.path.join(plugin.info.base_path, file))
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
logger.debug(
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
)
continue
plugin.run()
if __name__ == "__main__": # pragma: no cover
sys.argv = ["generate_cli.py", "--core"]
import sys
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
main()

View File

@@ -4,20 +4,21 @@ import inspect
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
import bec_widgets.cli.client as client
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from bec_lib.connector import MessageObject
import bec_widgets.cli.client as client
from bec_widgets.cli.client_utils import BECGuiClient
else:
client = lazy_import("bec_widgets.cli.client") # avoid circular import
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
# pylint: disable=protected-access
@@ -38,7 +39,7 @@ def rpc_call(func):
def wrapper(self, *args, **kwargs):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
caller_frame = inspect.currentframe().f_back
caller_frame = inspect.currentframe().f_back # type: ignore
while caller_frame:
if "jedi" in caller_frame.f_globals:
# Jedi module is present, likely tab completion
@@ -88,16 +89,20 @@ class RPCReference:
def __init__(self, registry: dict, gui_id: str) -> None:
self._registry = registry
self._gui_id = gui_id
self.object_name = self._registry[self._gui_id].object_name
@check_for_deleted_widget
def __getattr__(self, name):
if name in ["_registry", "_gui_id"]:
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__getattribute__(name)
return self._registry[self._gui_id].__getattribute__(name)
@check_for_deleted_widget
def __getitem__(self, key):
return self._registry[self._gui_id].__getitem__(key)
def __setattr__(self, name, value):
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__setattr__(name, value)
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
self._registry[self._gui_id].__setattr__(name, value)
def __repr__(self):
if self._gui_id not in self._registry:
@@ -114,19 +119,23 @@ class RPCReference:
return []
return self._registry[self._gui_id].__dir__()
def _is_deleted(self) -> bool:
return self._gui_id not in self._registry
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
name: str | None = None,
object_name: str | None = None,
parent=None,
**kwargs,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._name = name if name is not None else str(uuid.uuid4())[:5]
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
@@ -136,23 +145,23 @@ class RPCBase:
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.widget_name}>"
return f"<{qualname} with name: {self.object_name}>"
def remove(self):
"""
Remove the widget.
"""
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
if proxy := obj.get("container_proxy"):
assert isinstance(proxy, str)
self._run_rpc("remove", gui_id=proxy)
return
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self._name
@property
def _root(self):
def _root(self) -> BECGuiClient:
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
@@ -161,9 +170,17 @@ class RPCBase:
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
return parent # type: ignore
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
def _run_rpc(
self,
method,
*args,
wait_for_rpc_response=True,
timeout=5,
gui_id: str | None = None,
**kwargs,
) -> Any:
"""
Run the RPC call.
@@ -171,6 +188,8 @@ class RPCBase:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
@@ -179,7 +198,7 @@ class RPCBase:
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access
@@ -205,7 +224,11 @@ class RPCBase:
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
# we can assume that the response is a RequestResponseMessage, updated by
# the _on_rpc_response method
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
@@ -213,10 +236,10 @@ class RPCBase:
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()
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
parent._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -236,13 +259,14 @@ class RPCBase:
cls = getattr(client, cls)
# The namespace of the object will be updated dynamically on the client side
# Therefor it is important to check if the object is already in the registry
# Therefore it is important to check if the object is already in the registry
# If yes, we return the reference to the object, otherwise we create a new object
# pylint: disable=protected-access
if msg_result["gui_id"] in self._root._ipython_registry:
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
ret = cls(parent=self, **msg_result)
self._root._ipython_registry[ret._gui_id] = ret
self._refresh_references()
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
return obj
# return ret
@@ -258,3 +282,27 @@ class RPCBase:
if heart.status == messages.BECStatus.RUNNING:
return True
return False
def _refresh_references(self):
"""
Refresh the references.
"""
with self._root._lock:
references = {}
for key, val in self._root._server_registry.items():
parent_id = val["config"].get("parent_id")
if parent_id == self._gui_id:
references[key] = {
"gui_id": val["config"]["gui_id"],
"object_name": val["object_name"],
}
removed_references = set(self._rpc_references.keys()) - set(references.keys())
for key in removed_references:
delattr(self, self._rpc_references[key]["object_name"])
self._rpc_references = references
for key, val in references.items():
setattr(
self,
val["object_name"],
RPCReference(self._root._ipython_registry, val["gui_id"]),
)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import wraps
from threading import Lock, RLock
from threading import RLock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
@@ -65,7 +65,7 @@ class RPCRegister:
return register._broadcast_on_hold
@broadcast_update
def add_rpc(self, rpc: QObject):
def add_rpc(self, rpc: BECConnector):
"""
Add an RPC object to the register.
@@ -77,7 +77,7 @@ class RPCRegister:
self._rpc_register[rpc.gui_id] = rpc
@broadcast_update
def remove_rpc(self, rpc: str):
def remove_rpc(self, rpc: BECConnector):
"""
Remove an RPC object from the register.
@@ -113,7 +113,7 @@ class RPCRegister:
return connections
def get_names_of_rpc_by_class_type(
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
) -> list[str]:
"""Get all the names of the widgets.
@@ -123,7 +123,7 @@ class RPCRegister:
# This retrieves any rpc objects that are subclass of BECWidget,
# i.e. curve and image items are excluded
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
return [widget._name for widget in widgets]
return [widget.object_name for widget in widgets]
def broadcast(self):
"""
@@ -136,6 +136,18 @@ class RPCRegister:
for callback in self.callbacks:
callback(connections)
def object_is_registered(self, obj: BECConnector) -> bool:
"""
Check if an object is registered in the RPC register.
Args:
obj(QObject): The object to check.
Returns:
bool: True if the object is registered, False otherwise.
"""
return obj.gui_id in self._rpc_register
def add_callback(self, callback: Callable[[dict], None]):
"""
Add a callback that will be called whenever the registry is updated.
@@ -170,7 +182,8 @@ class RPCRegisterBroadcast:
def __exit__(self, *exc):
"""Exit the context manager"""
self._call_depth -= 1 # Remove nested calls
if self._call_depth == 0: # Last one to exit is repsonsible for broadcasting
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
self.rpc_register._skip_broadcast = False
self.rpc_register.broadcast()

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import get_custom_classes
class RPCWidgetHandler:
@@ -31,14 +31,12 @@ class RPCWidgetHandler:
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_custom_classes("bec_widgets")
self._widget_classes = {
self._widget_classes = get_all_plugin_widgets() | {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""
Create a widget from an RPC message.
@@ -52,7 +50,7 @@ class RPCWidgetHandler:
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(name=name, **kwargs)
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -1,188 +1,27 @@
from __future__ import annotations
import functools
import argparse
import json
import os
import signal
import sys
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
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 Qt, QTimer
from redis.exceptions import RedisError
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.utils.bec_dispatcher import BECDispatcher
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class BECWidgetsCLIServer:
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: type[BECDockArea] = BECDockArea,
gui_class_id: str = "bec",
) -> 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()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
with RPCRegister.delayed_broadcast():
self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
logger.error(f"Error while executing RPC instruction: {e}")
self.send_response(request_id, False, {"error": str(e)})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj):
if isinstance(obj, BECConnector):
config = obj.config.model_dump()
config["parent_id"] = obj.parent_id # add parent_id to config
return {
"gui_id": obj.gui_id,
"name": (
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
), # pylint: disable=protected-access
"widget_class": obj.__class__.__name__,
"config": config,
"__rpc__": True,
}
return obj
def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict):
"""
Broadcast the updated registry to all clients.
"""
# We only need to broadcast the dock areas
data = {key: self.serialize_object(val) for key, val in connections.items()}
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1, # only single message in stream
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down gui")
self.client.shutdown()
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class SimpleFileLikeFromLogOutputFunc:
@@ -203,40 +42,112 @@ class SimpleFileLikeFromLogOutputFunc:
return
def _start_server(
gui_id: str, gui_class: BECDockArea, gui_class_id: str = "bec", 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()
class GUIServer:
"""
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
"""
# 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, gui_class_id=gui_class_id
)
return server
def __init__(self, args):
self.config = args.config
self.gui_id = args.id
self.gui_class = args.gui_class
self.gui_class_id = args.gui_class_id
self.hide = args.hide
self.app: QApplication | None = None
self.launcher_window: LaunchWindow | None = None
self.dispatcher: BECDispatcher | None = None
def start(self):
"""
Start the GUI server.
"""
bec_logger.level = bec_logger.LOGLEVEL.INFO
if self.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
self._run()
def _get_service_config(self) -> ServiceConfig:
if self.config:
try:
config = json.loads(self.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()
return service_config
def _run(self):
"""
Run the GUI server.
"""
self.app = QApplication(sys.argv)
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.setup_bec_icon()
service_config = self._get_service_config()
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
widget.close()
if self.app:
self.app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(self.app.exec())
def setup_bec_icon(self):
"""
Set the BEC icon for the application
"""
if self.app is None:
return
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
def shutdown(self):
"""
Shutdown the GUI server.
"""
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()
def main():
import argparse
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
"""
Main entry point for subprocesses that start a GUI server.
"""
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, default="test", help="The id of the server")
@@ -256,69 +167,12 @@ def main():
args = parser.parse_args()
bec_logger.level = bec_logger.LOGLEVEL.INFO
if args.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
if args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECDockArea
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
# set close on last window, only if not under control of client ;
# indeed, Qt considers a hidden window a closed window, so if all windows
# are hidden by default it exits
app.setQuitOnLastWindowClosed(not args.hide)
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)
# store gui id within QApplication object, to make it available to all widgets
app.gui_id = args.id
# args.id = "abff6"
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC")
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
if not args.hide:
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets():
widget.close()
app.quit()
# gui.bec.close()
# win.shutdown()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(app.exec())
server = GUIServer(args)
server.start()
if __name__ == "__main__":
# import sys
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
main()

View File

@@ -43,22 +43,22 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"pg": pg,
"wh": wh,
"dock": self.dock,
"im": self.im,
"mi": self.mi,
"mm": self.mm,
"lm": self.lm,
"btn1": self.btn1,
"btn2": self.btn2,
"btn3": self.btn3,
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wf": self.wf,
"scatter": self.scatter,
"scatter_mi": self.scatter,
"mwf": self.mwf,
# "im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
# "btn2": self.btn2,
# "btn3": self.btn3,
# "btn4": self.btn4,
# "btn5": self.btn5,
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
}
)
@@ -77,76 +77,76 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
third_tab = QWidget()
third_tab_layout = QVBoxLayout(third_tab)
self.lm = LayoutManagerWidget()
third_tab_layout.addWidget(self.lm)
tab_widget.addTab(third_tab, "Layout Manager Widget")
fourth_tab = QWidget()
fourth_tab_layout = QVBoxLayout(fourth_tab)
self.pb = PlotBase()
self.pi = self.pb.plot_item
fourth_tab_layout.addWidget(self.pb)
tab_widget.addTab(fourth_tab, "PlotBase")
tab_widget.setCurrentIndex(3)
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
# self.lm = LayoutManagerWidget()
# third_tab_layout.addWidget(self.lm)
# tab_widget.addTab(third_tab, "Layout Manager Widget")
#
# fourth_tab = QWidget()
# fourth_tab_layout = QVBoxLayout(fourth_tab)
# self.pb = PlotBase()
# self.pi = self.pb.plot_item
# fourth_tab_layout.addWidget(self.pb)
# tab_widget.addTab(fourth_tab, "PlotBase")
#
# tab_widget.setCurrentIndex(3)
#
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
# Some buttons for layout testing
self.btn1 = QPushButton("Button 1")
self.btn2 = QPushButton("Button 2")
self.btn3 = QPushButton("Button 3")
self.btn4 = QPushButton("Button 4")
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wf = Waveform()
fifth_tab_layout.addWidget(self.wf)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
tab_widget.setCurrentIndex(4)
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image()
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(5)
seventh_tab = QWidget()
seventh_tab_layout = QVBoxLayout(seventh_tab)
self.scatter = ScatterWaveform()
self.scatter_mi = self.scatter.main_curve
self.scatter.plot("samx", "samy", "bpm4i")
seventh_tab_layout.addWidget(self.scatter)
tab_widget.addTab(seventh_tab, "Scatter Waveform")
tab_widget.setCurrentIndex(6)
eighth_tab = QWidget()
eighth_tab_layout = QVBoxLayout(eighth_tab)
self.mm = MotorMap()
eighth_tab_layout.addWidget(self.mm)
tab_widget.addTab(eighth_tab, "Motor Map")
tab_widget.setCurrentIndex(7)
ninth_tab = QWidget()
ninth_tab_layout = QVBoxLayout(ninth_tab)
self.mwf = MultiWaveform()
ninth_tab_layout.addWidget(self.mwf)
tab_widget.addTab(ninth_tab, "MultiWaveform")
tab_widget.setCurrentIndex(8)
# add stuff to the new Waveform widget
self._init_waveform()
self.setWindowTitle("Jupyter Console Window")
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
# self.btn2 = QPushButton("Button 2")
# self.btn3 = QPushButton("Button 3")
# self.btn4 = QPushButton("Button 4")
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
# sixth_tab = QWidget()
# sixth_tab_layout = QVBoxLayout(sixth_tab)
# self.im = Image()
# self.mi = self.im.main_image
# sixth_tab_layout.addWidget(self.im)
# tab_widget.addTab(sixth_tab, "Image Next Gen")
# tab_widget.setCurrentIndex(5)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
# self.scatter_mi = self.scatter.main_curve
# self.scatter.plot("samx", "samy", "bpm4i")
# seventh_tab_layout.addWidget(self.scatter)
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
# tab_widget.setCurrentIndex(6)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# ninth_tab = QWidget()
# ninth_tab_layout = QVBoxLayout(ninth_tab)
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(8)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
#
# self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
@@ -174,7 +174,7 @@ if __name__ == "__main__": # pragma: no cover
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
client = bec_dispatcher.client
client.start()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import os
import time
import traceback
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
@@ -10,21 +11,21 @@ from typing import TYPE_CHECKING, Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.dock import BECDock
else:
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
class ConnectionConfig(BaseModel):
@@ -82,19 +83,47 @@ class BECConnector:
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
name: str | None = None,
parent_dock: BECDock | None = None,
parent_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
**kwargs,
):
"""
BECConnector mixin class to handle BEC client and device manager.
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration with specific gui id.
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
**kwargs:
"""
# Extract object_name from kwargs to not pass it to Qt class
object_name = object_name or kwargs.pop("objectName", None)
# Ensure the parent is always the first argument for QObject
parent = kwargs.pop("parent", None)
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
super().__init__(parent=parent, **kwargs)
assert isinstance(
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# flag to check if the object was destroyed and its cleanup was called
self._destroyed = False
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
self._parent_dock = parent_dock
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
self.rpc_register = RPCRegister()
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@pyqtSlot()
@SafeSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
@@ -114,7 +143,6 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
self.parent_id = parent_id
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
@@ -122,14 +150,19 @@ class BECConnector:
self.gui_id: str = gui_id # Keep namespace in sync
else:
self.gui_id: str = self.config.gui_id # type: ignore
if name is None:
name = self.__class__.__name__
else:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
if object_name is not None:
self.setObjectName(object_name)
# 1) If no objectName is set, set the initial name
if not self.objectName():
self.setObjectName(self.__class__.__name__)
self.object_name = self.objectName()
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -138,7 +171,108 @@ class BECConnector:
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
QTimer.singleShot(0, self._update_object_name)
@property
def parent_id(self) -> str | None:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
return connector_parent.gui_id if connector_parent else None
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
@SafeSlot()
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def _update_object_name(self) -> None:
"""
Enforce a unique object name among siblings and register the object for RPC.
This method is called through a single shot timer kicked off in the constructor.
"""
# 1) Enforce unique objectName among siblings with the same BECConnector parent
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
def _enforce_unique_sibling_name(self):
"""
Enforce that this BECConnector has a unique objectName among its siblings.
Sibling logic:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
siblings = parent_bec.findChildren(BECConnector)
else:
# No parent => treat all top-level BECConnectors as siblings
# 1) Gather all BECConnectors from QApplication
all_widgets = QApplication.allWidgets()
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
# 2) "Top-level" means closest BECConnector parent is None
top_level_bec = [
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
]
# 3) We are among these top-level siblings
siblings = top_level_bec
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
base_name = self.object_name
if base_name not in used_names:
# Name is already unique among siblings
return
# Need a suffix to avoid collision
counter = 0
while True:
trial_name = f"{base_name}_{counter}"
if trial_name not in used_names:
self.setObjectName(trial_name)
self.object_name = trial_name
break
counter += 1
# pylint: disable=invalid-name
def setObjectName(self, name: str) -> None:
"""
Set the object name of the widget.
Args:
name (str): The new object name.
"""
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
@@ -266,7 +400,7 @@ class BECConnector:
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
# @pyqtSlot(str)
# @SafeSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
@@ -298,7 +432,7 @@ class BECConnector:
self.client = client
self.get_bec_shortcuts()
@pyqtSlot(ConnectionConfig) # TODO can be also dict
@SafeSlot(ConnectionConfig) # TODO can be also dict
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
@@ -316,8 +450,9 @@ class BECConnector:
def remove(self):
"""Cleanup the BECConnector"""
# If the widget is attached to a dock, remove it from the dock.
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
if self._parent_dock is not None:
self._parent_dock.delete(self._name)
self._parent_dock.delete(self.object_name)
# If the widget is from Qt, trigger its close method.
elif hasattr(self, "close"):
self.close()

View File

@@ -10,13 +10,15 @@ from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
@@ -76,7 +78,7 @@ def list_editable_packages() -> set[str]:
return editable_packages
def patch_designer(): # pragma: no cover
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
@@ -117,7 +119,7 @@ def patch_designer(): # pragma: no cover
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
def find_plugin_paths(base_path: Path):
@@ -145,15 +147,24 @@ def set_plugin_environment_variable(plugin_paths):
# Patch the designer function
def main(): # pragma: no cover
def open_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
set_plugin_environment_variable(plugin_paths)
patch_designer()
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
if __name__ == "__main__": # pragma: no cover

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
@@ -12,17 +14,31 @@ from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils.serialization import register_serializer_extension
logger = bec_logger.logger
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.rpc_server import RPCServer
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
cb_signal = pyqtSignal(dict, dict)
def __init__(self, cb):
def __init__(self, cb: Callable, cb_info: dict | None = None):
"""
Initialize the QtThreadSafeCallback.
Args:
cb (Callable): The callback function to be wrapped.
cb_info (dict, optional): Additional information about the callback. Defaults to None.
"""
super().__init__()
self.cb_info = cb_info
self.cb = cb
self.cb_signal.connect(self.cb)
@@ -31,7 +47,7 @@ class QtThreadSafeCallback(QObject):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return id(self.cb)
return f"{id(self.cb)}{self.cb_info}".__hash__()
def __call__(self, msg_content, metadata):
self.cb_signal.emit(msg_content, metadata)
@@ -73,14 +89,23 @@ class BECDispatcher:
_instance = None
_initialized = False
client: BECClient
cli_server: RPCServer | None = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
def __new__(
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
*args,
**kwargs,
):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None, config: str | ServiceConfig = None):
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
if self._initialized:
return
@@ -107,11 +132,18 @@ class BECDispatcher:
except redis.exceptions.ConnectionError:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
register_serializer_extension()
logger.success("Initialized BECDispatcher")
self.start_cli_server(gui_id=gui_id)
self._initialized = True
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance of the BECDispatcher.
"""
cls._instance = None
cls._initialized = False
@@ -119,6 +151,7 @@ class BECDispatcher:
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
cb_info: dict | None = None,
**kwargs,
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
@@ -127,8 +160,9 @@ class BECDispatcher:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
slot = QtThreadSafeCallback(slot)
slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
self.client.connector.register(topics, cb=slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
@@ -178,4 +212,49 @@ class BECDispatcher:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
# pylint: disable=protected-access
self.disconnect_topics(self.client.connector._topics_cb)
def start_cli_server(self, gui_id: str | None = None):
"""
Start the CLI server.
Args:
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
"""
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.rpc_server import RPCServer
if gui_id is None:
gui_id = self.generate_unique_identifier()
if not self.client.started:
logger.error("Cannot start CLI server without a running client")
return
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
logger.success(f"Started CLI server with gui_id: {gui_id}")
def stop_cli_server(self):
"""
Stop the CLI server.
"""
if self.cli_server is None:
logger.error("Cannot stop CLI server without starting it first")
return
self.cli_server.shutdown()
self.cli_server = None
logger.success("Stopped CLI server")
@staticmethod
def generate_unique_identifier(length: int = 4) -> str:
"""
Generate a unique identifier for the application.
Args:
length: The length of the identifier. Defaults to 4.
Returns:
str: The unique identifier.
"""
allowed_chars = string.ascii_lowercase + string.digits
return "".join(random.choices(allowed_chars, k=length))

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import importlib.metadata
import inspect
import pkgutil
from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_widgets.utils.bec_widget import BECWidget
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
"""Return specs for all submodules of the given module."""
return tuple(
module_info.module_finder.find_spec(module_info.name)
for module_info in pkgutil.iter_modules(module.__path__)
if isinstance(module_info.module_finder, FileFinder)
)
def _loaded_submodules_from_specs(
submodule_specs: tuple[ModuleSpec | None, ...],
) -> Generator[ModuleType, None, None]:
"""Load all submodules from the given specs."""
for submodule in (
importlib_util.module_from_spec(spec) for spec in submodule_specs if spec is not None
):
assert isinstance(
submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!"
submodule.__loader__.exec_module(submodule)
yield submodule
def _submodule_by_name(module: ModuleType, name: str):
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
if submod.__name__ == name:
return submod
return None
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their names."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
return dict(
inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
)
def _all_widgets_from_all_submods(module):
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"):
return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets.update(_all_widgets_from_all_submods(submod))
return widgets
def user_widget_plugin() -> ModuleType | None:
plugins = importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
return None if len(plugins) == 0 else tuple(plugins)[0].load()
def get_plugin_client_module() -> ModuleType | None:
"""If there is a plugin repository installed, return the client module."""
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin)
else:
return {}
if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module()
...

View File

@@ -1,6 +1,6 @@
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
will allow you to decide by yourself when to unblock and execute the callback again."""

View File

@@ -4,8 +4,8 @@ from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication, QWidget
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
@@ -32,9 +32,7 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
name: str | None = None,
parent_dock: BECDock | None = None,
parent_id: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
**kwargs,
):
"""
@@ -54,17 +52,12 @@ class BECWidget(BECConnector):
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(
client=client,
config=config,
gui_id=gui_id,
name=name,
parent_dock=parent_dock,
parent_id=parent_id,
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
@@ -107,11 +100,14 @@ class BECWidget(BECConnector):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
self.cleanup()
if not self._destroyed:
self.cleanup()
self._destroyed = True
finally:
super().closeEvent(event) # pylint: disable=no-member

View File

@@ -11,7 +11,7 @@ from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors

View File

@@ -266,3 +266,5 @@ class CompactPopupWidget(QWidget):
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import itertools
from typing import Literal, Type
from typing import Any, Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.cli.client_utils import BECGuiClient
class WidgetContainerUtils:
@@ -73,3 +72,36 @@ class WidgetContainerUtils:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
@staticmethod
def name_is_protected(name: str, container: Any = None) -> bool:
"""
Check if the name is not protected.
Args:
name(str): The name to be checked.
Returns:
bool: True if the name is not protected, False otherwise.
"""
if container is None:
container = BECGuiClient
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
return name in gui_client_methods
@staticmethod
def raise_for_invalid_name(name: str, container: Any = None) -> None:
"""
Check if the name is valid. If not, raise a ValueError.
Args:
name(str): The name to be checked.
Raises:
ValueError: If the name is not valid.
"""
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
if WidgetContainerUtils.name_is_protected(name, container):
raise ValueError(f"Name '{name}' is protected. Please choose another name.")

View File

@@ -96,23 +96,55 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
otherwise error display is left to the original exception hook
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
useful to prevent function calls from already deleted objects.
'raise_error' keyword argument can be passed with boolean value if the error should be raised
after the error is displayed. This is useful to propagate the error to the caller but should be used
with great care to avoid segfaults.
The keywords above are stored in a container which can be overridden by passing
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
This is useful to override the default behavior of the decorator for a specific function call.
"""
popup_error = bool(slot_kwargs.pop("popup_error", False))
_slot_params = {
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
}
def error_managed(method):
@Slot(*slot_args, **slot_kwargs)
@functools.wraps(method)
def wrapper(*args, **kwargs):
_override_slot_params = kwargs.pop("_override_slot_params", {})
_slot_params.update(_override_slot_params)
try:
if not _slot_params["verify_sender"] or len(args) == 0:
return method(*args, **kwargs)
_instance = args[0]
if not isinstance(_instance, QObject):
return method(*args, **kwargs)
sender = _instance.sender()
if sender is None:
logger.info(
f"Sender is None for {method.__module__}.{method.__qualname__}, "
"skipping method call."
)
return
return method(*args, **kwargs)
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
if _slot_params["popup_error"]:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
if _slot_params["raise_error"]:
raise
return wrapper

View File

@@ -37,7 +37,7 @@ class ExpandableGroupFrame(QFrame):
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget()
self._contents = QWidget(self)
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
logger = bec_logger.logger
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
"""
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
super().__init__(parent=parent, client=client, **kwargs)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._form_grid_container = QWidget(parent=self)
self._form_grid = QWidget(parent=self._form_grid_container)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self.populate()
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid: QGridLayout = self._form_grid.layout() # type: ignore
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
"""
A form generated from a pydantic model.
Args:
metadata_model (type[BaseModel]): the model class for which to generate a form.
"""
self._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form)
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
def _form_item_specs(self):
return [
FormItemSpec(name=name, info=info, item_type=info.annotation)
for name, info in self._md_schema.model_fields.items()
]
def update_items_from_schema(self):
self._items = self._form_item_specs()
def populate(self):
self.update_items_from_schema()
super().populate()
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._dict_from_grid()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
return False

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from typing import TYPE_CHECKING, Callable, get_args
from types import UnionType
from typing import Callable, Protocol
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
@@ -33,12 +35,22 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_precision,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
name: str
info: FieldInfo = FieldInfo()
class ClearableBoolEntry(QWidget):
stateChanged = Signal()
@@ -82,21 +94,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
class MetadataWidget(QWidget):
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent)
self._info = info
self._spec = spec
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
self._default = field_default(self._info)
self._desc = self._info.description
self._default = field_default(self._spec.info)
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(info):
if clearable_required(spec.info):
self._add_clear_button()
@abstractmethod
@@ -127,15 +138,15 @@ class MetadataWidget(QWidget):
self.valueChanged.emit()
class StrMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class StrMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QLineEdit()
self._layout.addWidget(self._main_widget)
min_length, max_length = field_minlen(self._info), field_maxlen(self._info)
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
if max_length:
self._main_widget.setMaxLength(max_length)
self._main_widget.setToolTip(
@@ -156,15 +167,15 @@ class StrMetadataField(MetadataWidget):
self._main_widget.setText(value)
class IntMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class IntMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._info, int)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
@@ -185,18 +196,18 @@ class IntMetadataField(MetadataWidget):
self._main_widget.setValue(value)
class FloatDecimalMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class FloatDecimalMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._info, int)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._info)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -219,13 +230,13 @@ class FloatDecimalMetadataField(MetadataWidget):
self._main_widget.setValue(value)
class BoolMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class BoolMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
if clearable_required(self._info):
if clearable_required(self._spec.info):
self._main_widget = ClearableBoolEntry()
else:
self._main_widget = QCheckBox()
@@ -240,7 +251,7 @@ class BoolMetadataField(MetadataWidget):
self._main_widget.setChecked(value)
def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:

View File

@@ -1,17 +1,30 @@
import inspect
import os
import re
from typing import NamedTuple
from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
class PluginFilenames(NamedTuple):
register: str
plugin: str
pyproj: str
def plugin_filenames(name: str) -> PluginFilenames:
return PluginFilenames(f"register_{name}.py", f"{name}_plugin.py", f"{name}.pyproject")
class DesignerPluginInfo:
def __init__(self, plugin_class):
self.plugin_class = plugin_class
self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
@@ -27,21 +40,6 @@ class DesignerPluginInfo:
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
@staticmethod
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()
class DesignerPluginGenerator:
def __init__(self, widget: type):
@@ -53,11 +51,15 @@ class DesignerPluginGenerator:
self._excluded = True
return
self.templates = {}
self.templates: dict[str, str] = {}
self.template_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
@property
def filenames(self):
return plugin_filenames(self.info.plugin_name_snake)
def run(self, validate=True):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
@@ -107,31 +109,33 @@ class DesignerPluginGenerator:
or bool(init_source.find("super().__init__(parent)") > 0)
)
# for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or 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_file(self, name: str, contents: str):
with open(os.path.join(self.info.base_path, name), "w", encoding="utf-8") as f:
f.write(contents)
def _format(self, name: str):
return self.templates[name].format(**self.info.__dict__)
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))
self._write_file(self.filenames.register, self._format("register"))
self._write_file(self.filenames.plugin, self._format("plugin"))
pyproj = str({"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]})
self._write_file(self.filenames.pyproj, pyproj)
def _load_templates(self):
for file in os.listdir(self.template_path):

View File

@@ -1,5 +1,7 @@
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots. """
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots."""
from __future__ import annotations
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot

View File

@@ -0,0 +1,16 @@
import re
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()

View File

@@ -22,10 +22,10 @@ class PaletteViewer(BECWidget, QWidget):
"""
ICON_NAME = "palette"
RPC = False
def __init__(self, *args, parent=None, **kwargs):
super().__init__(*args, theme_update=True, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, theme_update=True, **kwargs)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -9,6 +12,9 @@ from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
@@ -45,6 +51,40 @@ def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
specified in the respective pyproject.toml file using the following key:
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
e.g.
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {}
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated auto update {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_auto_updates(obj):
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
return (
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
)
@dataclass
class BECClassInfo:
name: str
@@ -58,7 +98,13 @@ class BECClassInfo:
class BECClassContainer:
def __init__(self):
self._collection = []
self._collection: list[BECClassInfo] = []
def __repr__(self):
return str(list(cl.name for cl in self.collection))
def __iter__(self):
return self._collection.__iter__()
def add_class(self, class_info: BECClassInfo):
"""

View File

@@ -16,6 +16,7 @@ class RoundedFrame(QFrame):
parent=None,
content_widget: QWidget = None,
background_color: str = None,
orientation: str = "horizontal",
radius: int = 10,
):
QFrame.__init__(self, parent)
@@ -28,8 +29,12 @@ class RoundedFrame(QFrame):
self.setObjectName("roundedFrame")
# Create a layout for the frame
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
if orientation == "vertical":
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
else:
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, TypeVar
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from qtpy.QtCore import QObject
else:
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
T = TypeVar("T")
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class RPCServer:
client: BECClient
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher | None = None,
client: BECClient | None = None,
config=None,
gui_class_id: str = "bec",
) -> 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()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
if request_id is None:
logger.error("Received RPC instruction without request_id")
return
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception:
content = traceback.format_exc()
logger.error(f"Error while executing RPC instruction: {content}")
self.send_response(request_id, False, {"error": content})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj: T) -> None | dict | T:
"""
Serialize all BECConnector objects.
Args:
obj: The object to be serialized.
Returns:
None | dict | T: The serialized object or None if the object is not a BECConnector.
"""
if not isinstance(obj, BECConnector):
return obj
# Respect RPC = False
if getattr(obj, "RPC", True) is False:
return None
return self._serialize_bec_connector(obj, wait=True)
def emit_heartbeat(self) -> None:
"""
Emit a heartbeat message to the GUI server.
This method is called periodically to indicate that the server is still running.
"""
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict) -> None:
"""
Broadcast the registry update to all the callbacks.
This method is called whenever the registry is updated.
"""
data = {}
for key, val in connections.items():
if not isinstance(val, BECConnector):
continue
if not getattr(val, "RPC", True):
continue
data[key] = self._serialize_bec_connector(val)
if self._broadcasted_data == data:
return
self._broadcasted_data = data
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
"""
Create the serialization dict for a single BECConnector.
Args:
connector (BECConnector): The BECConnector to serialize.
wait (bool): If True, wait until the object is registered in the RPC register.
Returns:
dict: The serialized BECConnector object.
"""
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
try:
parent = connector.parent()
if isinstance(parent, BECMainWindow):
container_proxy = parent.gui_id
else:
container_proxy = None
except Exception:
container_proxy = None
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
widget_class = connector.__class__.__name__
return {
"gui_id": connector.gui_id,
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
}
@staticmethod
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
# Suppose clients register callbacks to receive updates
def add_registry_update_callback(self, cb: Callable) -> None:
"""
Add a callback to be called whenever the registry is updated.
The specified callback is called whenever the registry is updated.
Args:
cb (Callable): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self._registry_update_callbacks.append(cb)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -0,0 +1,44 @@
from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF
def register_serializer_extension():
"""
Register the serializer extension for the BECConnector.
"""
if not module_is_registered("bec_widgets.utils.serialization"):
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
def module_is_registered(module_name: str) -> bool:
"""
Check if the module is registered in the encoder.
Args:
module_name (str): The name of the module to check.
Returns:
bool: True if the module is registered, False otherwise.
"""
# pylint: disable=protected-access
for enc in msgpack._encoder:
if enc[0].__module__ == module_name:
return True
return False
def encode_qpointf(obj):
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
def decode_qpointf(obj):
"""
no-op function since QPointF is encoded as a list of floats.
"""
return obj

View File

@@ -1,7 +1,11 @@
from bec_lib.logger import bec_logger
from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class SettingWidget(QWidget):
"""
@@ -37,6 +41,15 @@ class SettingWidget(QWidget):
"""
pass
def cleanup(self):
"""
Cleanup the settings widget.
"""
def closeEvent(self, event: QCloseEvent) -> None:
self.cleanup()
return super().closeEvent(event)
class SettingsDialog(QDialog):
"""
@@ -99,8 +112,17 @@ class SettingsDialog(QDialog):
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
self.cleanup()
super().accept()
@SafeSlot()
def reject(self):
"""
Reject the changes made in the settings widget and close the dialog.
"""
self.cleanup()
super().reject()
@SafeSlot()
def apply_changes(self):
"""
@@ -114,7 +136,10 @@ class SettingsDialog(QDialog):
"""
self.button_box.close()
self.button_box.deleteLater()
self.widget.close()
self.widget.deleteLater()
def closeEvent(self, event):
logger.info("Closing settings dialog")
self.cleanup()
super().closeEvent(event)

View File

@@ -35,7 +35,6 @@ class SidePanel(QWidget):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("SidePanel")
self._orientation = orientation
self._panel_max_width = panel_max_width
@@ -60,7 +59,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -90,7 +89,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)

View File

@@ -118,7 +118,7 @@ class IconAction(ToolBarAction):
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 = QAction(icon=icon, text=self.tooltip, parent=target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
@@ -128,7 +128,7 @@ class QtIconAction(ToolBarAction):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(self.icon, self.tooltip, parent)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
@@ -173,7 +173,7 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled,
color=self.color,
)
self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
@@ -212,12 +212,12 @@ class DeviceSelectionAction(ToolBarAction):
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
widget = QWidget(parent=target)
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(f"{self.label}")
label = QLabel(text=f"{self.label}", parent=target)
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
@@ -280,7 +280,9 @@ class SwitchableToolBarAction(ToolBarAction):
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action = QAction(
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
@@ -369,13 +371,13 @@ class WidgetAction(ToolBarAction):
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget()
self.container = QWidget(parent=target)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(f"{self.label}")
label_widget = QLabel(text=f"{self.label}", parent=target)
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
@@ -437,7 +439,7 @@ class ExpandableMenuAction(ToolBarAction):
)
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
sub_action = QAction(text=action.tooltip, parent=target)
sub_action.setIconVisibleInMenu(True)
if action.icon_path:
icon = QIcon()
@@ -521,7 +523,7 @@ class ModularToolBar(QToolBar):
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent)
super().__init__(parent=parent)
self.widgets = defaultdict(dict)
self.background_color = background_color
@@ -858,7 +860,7 @@ class MainWindow(QMainWindow): # pragma: no cover
# For theme testing
self.dark_button = DarkModeButton(toolbar=True)
self.dark_button = DarkModeButton(parent=self, toolbar=True)
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
self.toolbar.add_action("dark_mode", dark_mode_action, self)

View File

@@ -1,16 +1,17 @@
import os
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict = None):
def __init__(self, baseinstance, custom_widgets: dict | None = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
@@ -18,10 +19,9 @@ if PYSIDE6:
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)
widget = self.custom_widgets[class_name](self.baseinstance)
return widget
return super().createWidget(class_name, parent, name)
return super().createWidget(class_name, self.baseinstance, name)
class UILoader:
@@ -51,7 +51,7 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
parent = parent or self.parent
loader = CustomUiLoader(parent, self.custom_widgets)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import shiboken6 as shb
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -275,39 +276,162 @@ class WidgetHierarchy:
grab_values: bool = False,
prefix: str = "",
exclude_internal_widgets: bool = True,
only_bec_widgets: bool = False,
show_parent: bool = True,
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of
widget: Widget to print the hierarchy of.
indent(int, optional): Level of indentation.
grab_values(bool,optional): Whether to grab the values of the widgets.
prefix(stc,optional): Custom string prefix for indentation.
prefix(str,optional): Custom string prefix for indentation.
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
"""
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
if grab_values:
value = WidgetIO.get_value(widget, ignore_errors=True)
value_str = f" [value: {value}]" if value is not None else ""
widget_info += value_str
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
is_bec = isinstance(widget, BECConnector)
if only_bec_widgets and not is_bec:
return
# 2) Determine and print the parent's info (closest BECConnector)
parent_info = ""
if show_parent and is_bec:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
if ancestor:
parent_label = ancestor.objectName() or ancestor.__class__.__name__
parent_info = f" parent={parent_label}"
else:
parent_info = " parent=None"
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
print(prefix + widget_info)
children = widget.children()
for child in children:
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
# 3) If it's a Waveform, explicitly print the curves
if isinstance(widget, Waveform):
for curve in widget.curves:
curve_prefix = prefix + " └─ "
print(
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
f"parent={widget.objectName()}"
)
# 4) Recursively handle each child if:
# - It's a QWidget
# - It is a BECConnector (or we don't care about filtering)
# - Its closest BECConnector parent is the current widget
for child in widget.findChildren(QWidget):
if only_bec_widgets and not isinstance(child, BECConnector):
continue
child_prefix = prefix + " "
arrow = "├─ " if child != children[-1] else "└─ "
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
child_prefix = prefix + " └─ "
WidgetHierarchy.print_widget_hierarchy(
child, indent + 1, grab_values, prefix=child_prefix + arrow
child,
indent=indent + 1,
grab_values=grab_values,
prefix=child_prefix,
exclude_internal_widgets=exclude_internal_widgets,
only_bec_widgets=only_bec_widgets,
show_parent=show_parent,
)
@staticmethod
def print_becconnector_hierarchy_from_app():
"""
Enumerate ALL BECConnector objects in the QApplication.
Also detect if a widget is a PlotBase, and add any data items
(PlotDataItem-like) that are also BECConnector objects.
Build a parent->children graph where each child's 'parent'
is its closest BECConnector ancestor. Print the entire hierarchy
from the root(s).
The result is a single, consolidated tree for your entire
running GUI, including PlotBase data items that are BECConnector.
"""
import sys
from collections import defaultdict
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.plot_base import PlotBase
# 1) Gather ALL QWidget-based BECConnector objects
all_qwidgets = QApplication.allWidgets()
bec_widgets = set(w for w in all_qwidgets if isinstance(w, BECConnector))
# 2) Also gather any BECConnector-based data items from PlotBase widgets
for w in all_qwidgets:
if isinstance(w, PlotBase) and hasattr(w, "plot_item"):
plot_item = w.plot_item
if hasattr(plot_item, "listDataItems"):
for data_item in plot_item.listDataItems():
if isinstance(data_item, BECConnector):
bec_widgets.add(data_item)
# 3) Build a map of (closest BECConnector parent) -> list of children
parent_map = defaultdict(list)
for w in bec_widgets:
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
parent_map[parent_bec].append(w)
# 4) Define a recursive printer to show each object's children
def print_tree(parent, prefix=""):
children = parent_map[parent]
for i, child in enumerate(children):
connector_class = child.__class__.__name__
connector_name = child.objectName() or connector_class
if parent is None:
parent_label = "None"
else:
parent_label = parent.objectName() or parent.__class__.__name__
line = f"{connector_class} ({connector_name}) parent={parent_label}"
# Determine tree-branch symbols
is_last = i == len(children) - 1
branch_str = "└─ " if is_last else "├─ "
print(prefix + branch_str + line)
# Recurse deeper
next_prefix = prefix + (" " if is_last else "")
print_tree(child, prefix=next_prefix)
# 5) Print top-level items (roots) whose BECConnector parent is None
roots = parent_map[None]
for r_i, root in enumerate(roots):
root_class = root.__class__.__name__
root_name = root.objectName() or root_class
line = f"{root_class} ({root_name}) parent=None"
is_last_root = r_i == len(roots) - 1
print(line)
# Recurse into its children
print_tree(root, prefix=" ")
@staticmethod
def _get_becwidget_ancestor(widget):
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
from bec_widgets.utils import BECConnector
if not shb.isValid(widget):
return None
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
@staticmethod
def export_config_to_dict(
widget: QWidget,

View File

@@ -0,0 +1,364 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"
def __init__(
self, parent=None, gui_id: str = None, window_title="Auto Update", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None
self._default_dock = None # type:ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = True
self.start_auto_update()
def start_auto_update(self):
"""
Establish all connections for the auto updates.
"""
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
def stop_auto_update(self):
"""
Disconnect all connections for the auto updates.
"""
self.bec_dispatcher.disconnect_slot(
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
)
@property
def selected_device(self) -> str | None:
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
return self._auto_update_selected_device
@selected_device.setter
def selected_device(self, value: str | None) -> None:
"""
Set the selected device in the auto update config.
Args:
value(str): The selected device.
"""
self._auto_update_selected_device = value
@SafeSlot()
def _on_scan_status(self, content: dict, metadata: dict) -> None:
"""
Callback for scan status messages.
"""
msg = ScanStatusMessage(**content, metadata=metadata)
if not self.enabled:
return
self.enable_gui_highlights(True)
match msg.status:
case "open":
self.on_scan_open(msg)
case "closed":
self.on_scan_closed(msg)
case ["aborted", "halted"]:
self.on_scan_abort(msg)
case _:
pass
def start_default_dock(self):
"""
Create a default dock for the auto updates.
"""
self.dock_name = "update_dock"
self._default_dock = self.dock_area.new(self.dock_name)
self.current_widget = self._default_dock.new("Waveform")
@overload
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["Image"]) -> Image: ...
@overload
def set_dock_to_widget(self, widget: Literal["ScatterWaveform"]) -> ScatterWaveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["MotorMap"]) -> MotorMap: ...
@overload
def set_dock_to_widget(self, widget: Literal["MultiWaveform"]) -> MultiWaveform: ...
def set_dock_to_widget(
self,
widget: Literal["Waveform", "Image", "ScatterWaveform", "MotorMap", "MultiWaveForm"] | str,
) -> BECWidget:
"""
Set the dock to the widget.
Args:
widget (str): The widget to set the dock to. Must be the name of a valid widget class.
Returns:
BECWidget: The widget that was set.
"""
if self._default_dock is None or self.current_widget is None:
logger.warning(
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
)
self.start_default_dock()
assert self.current_widget is not None
if not self.current_widget.__class__.__name__ == widget:
self._default_dock.delete(self.current_widget.object_name)
self.current_widget = self._default_dock.new(widget)
return self.current_widget
def get_selected_device(
self, monitored_devices, selected_device: str | None = None
) -> str | None:
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device is None:
selected_device = self.selected_device
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def enable_gui_highlights(self, enable: bool) -> None:
"""
Enable or disable GUI highlights.
Args:
enable (bool): Whether to enable or disable the highlights.
"""
if enable:
title = self.dock_area.window().windowTitle()
if " [Auto Updates]" in title:
return
self.dock_area.window().setWindowTitle(f"{title} [Auto Updates]")
else:
title = self.dock_area.window().windowTitle()
self.dock_area.window().setWindowTitle(title.replace(" [Auto Updates]", ""))
@property
def enabled(self) -> bool:
"""
Get the enabled status of the auto updates.
"""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
"""
Set the enabled status of the auto updates.
"""
if self._enabled == value:
return
self._enabled = value
if value:
self.start_auto_update()
self.enable_gui_highlights(True)
self.on_start()
else:
self.stop_auto_update()
self.enable_gui_highlights(False)
self.on_stop()
def cleanup(self) -> None:
"""
Cleanup procedure to run when the auto updates are disabled.
"""
self.enabled = False
self.stop_auto_update()
self.dock_area.close()
self.dock_area.deleteLater()
self.dock_area = None
super().cleanup()
########################################################################
################# Update Functions #####################################
########################################################################
def simple_line_scan(self, info: ScanStatusMessage) -> None:
"""
Simple line scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Get the scan report devices reported by the scan
dev_x = info.scan_report_devices[0] # type:ignore
# For the y axis, get the selected device
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Clear the waveform widget and plot the data
# with the scan number and device names
# as the label and title
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
)
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
"""
Simple grid scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the scatter waveform widget
scatter = self.set_dock_to_widget("ScatterWaveform")
# Get the scan report devices reported by the scan
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if None in (dev_x, dev_y, dev_z):
return
# Clear the scatter waveform widget and plot the data
scatter.clear_all()
scatter.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
logger.info(
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
)
def best_effort(self, info: ScanStatusMessage) -> None:
"""
Best effort scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# If the scan report devices are empty, there is nothing we can do
if not info.scan_report_devices:
return
dev_x = info.scan_report_devices[0] # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Clear the waveform widget and plot the data
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
#######################################################################
################# GUI Callbacks #######################################
#######################################################################
def on_start(self) -> None:
"""
Procedure to run when the auto updates are enabled.
"""
self.start_default_dock()
def on_stop(self) -> None:
"""
Procedure to run when the auto updates are disabled.
"""
def on_scan_open(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan starts.
Args:
msg (ScanStatusMessage): The scan status message.
"""
if msg.scan_name == "line_scan" and msg.scan_report_devices:
return self.simple_line_scan(msg)
if msg.scan_name == "grid_scan" and msg.scan_report_devices:
return self.simple_grid_scan(msg)
if msg.scan_report_devices:
return self.best_effort(msg)
return None
def on_scan_closed(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan ends.
Args:
msg (ScanStatusMessage): The scan status message.
"""
def on_scan_abort(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan is aborted.
Args:
msg (ScanStatusMessage): The scan status message.
"""

View File

@@ -12,10 +12,11 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
@@ -130,9 +131,9 @@ class BECDock(BECWidget, Dock):
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
parent_id: str | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
client=None,
gui_id: str | None = None,
closable: bool = True,
@@ -148,12 +149,17 @@ class BECDock(BECWidget, Dock):
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
super().__init__(
client=client, config=config, gui_id=gui_id, name=name, parent_id=parent_id
) # Name was checked and created in BEC Widget
label = CustomDockLabel(text=name, closable=closable)
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
# Dock.__init__(self, name=name, **kwargs)
super().__init__(
parent=parent_dock_area,
name=name,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
label=label,
**kwargs,
)
self.parent_dock_area = parent_dock_area
# Layout Manager
@@ -193,7 +199,7 @@ class BECDock(BECWidget, Dock):
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget._name, widget) for widget in self.element_list)
return dict((widget.object_name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
@@ -265,13 +271,15 @@ class BECDock(BECWidget, Dock):
"""
return list(widget_handler.widget_classes.keys())
def _get_list_of_widget_name_of_parent_dock_area(self):
docks = self.parent_dock_area.panel_list
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
if (docks := self.parent_dock_area.panel_list) is None:
return []
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
@@ -294,28 +302,15 @@ class BECDock(BECWidget, Dock):
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
# row = cast(int, self.layout.rowCount()) # type:ignore
row = self.layout.rowCount()
# row = cast(int, row)
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
if name is not None: # Name is provided
if name in existing_widgets_parent_dock:
# pylint: disable=protected-access
raise ValueError(
f"Name {name} must be unique for widgets, but already exists in DockArea "
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
)
else: # Name is not provided
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
name = WidgetContainerUtils.generate_unique_name(
name=widget_class_name, list_of_names=existing_widgets_parent_dock
)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
@@ -325,16 +320,16 @@ class BECDock(BECWidget, Dock):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget, name=name, parent_dock=self, parent_id=self.gui_id
widget_type=widget, object_name=name, parent_dock=self, parent=self
),
)
else:
widget._name = name # pylint: disable=protected-access
widget.object_name = name
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if hasattr(widget, "config"):
widget.config.gui_id = widget.gui_id
self.config.widgets[widget._name] = widget.config # pylint: disable=protected-access
self.config.widgets[widget.object_name] = widget.config
return widget
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
@@ -364,7 +359,7 @@ class BECDock(BECWidget, Dock):
"""
Remove the dock from the parent dock area.
"""
self.parent_dock_area.delete(self._name)
self.parent_dock_area.delete(self.object_name)
def delete(self, widget_name: str) -> None:
"""
@@ -374,7 +369,7 @@ class BECDock(BECWidget, Dock):
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget._name == widget_name]
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
@@ -390,7 +385,7 @@ class BECDock(BECWidget, Dock):
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget._name, None)
self.config.widgets.pop(widget.object_name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
@@ -400,7 +395,7 @@ class BECDock(BECWidget, Dock):
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget._name) # pylint: disable=protected-access
self.delete(widget.object_name)
def cleanup(self):
"""
@@ -421,6 +416,7 @@ class BECDock(BECWidget, Dock):
self.delete_all()
self.widgets.clear()
super().cleanup()
self.deleteLater()
def close(self):
"""

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
@@ -15,13 +14,16 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
)
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
@@ -47,6 +49,10 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
"""
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
"""
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
@@ -62,7 +68,6 @@ class BECDockArea(BECWidget, QWidget):
"remove",
"detach_dock",
"attach_all",
"selected_device",
"save_state",
"restore_state",
]
@@ -73,7 +78,7 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
name: str | None = None,
object_name: str = None,
**kwargs,
) -> None:
if config is None:
@@ -82,17 +87,25 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
QWidget.__init__(self, parent=parent)
self._parent = parent
super().__init__(
parent=parent,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
**kwargs,
)
self._parent = parent # TODO probably not needed
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self._instructions_visible = True
self.dock_area = DockArea()
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(
parent=self,
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
@@ -169,10 +182,10 @@ class BECDockArea(BECWidget, QWidget):
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget()
self.spacer = QWidget(parent=self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self.toolbar.addWidget(self.dark_mode_button)
self._hook_toolbar()
def minimumSizeHint(self):
@@ -230,7 +243,8 @@ class BECDockArea(BECWidget, QWidget):
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
name = pascal_to_snake(widget_name)
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
@@ -243,17 +257,6 @@ class BECDockArea(BECWidget, QWidget):
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def selected_device(self) -> str:
gui_id = QApplication.instance().gui_id
auto_update_config = self.client.connector.get(
MessageEndpoints.gui_auto_update_config(gui_id)
)
try:
return auto_update_config.selected_device
except AttributeError:
return None
@property
def panels(self) -> dict[str, BECDock]:
"""
@@ -305,6 +308,8 @@ class BECDockArea(BECWidget, QWidget):
"""
if state is None:
state = self.config.docks_state
if state is None:
return
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
@@ -352,17 +357,27 @@ class BECDockArea(BECWidget, QWidget):
Returns:
BECDock: The created dock.
"""
dock_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
dock_names = [
dock.object_name for dock in self.panel_list
] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self._name} and id {self.gui_id}."
f"with name: {self.object_name} and id {self.gui_id}."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(name=name, parent_dock_area=self, parent_id=self.gui_id, closable=closable)
dock = BECDock(
parent=self,
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
closable=closable,
)
dock.config.position = position
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
@@ -430,10 +445,8 @@ class BECDockArea(BECWidget, QWidget):
Cleanup the dock area.
"""
self.delete_all()
self.toolbar.close()
self.toolbar.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
super().cleanup()
def show(self):
@@ -482,7 +495,18 @@ class BECDockArea(BECWidget, QWidget):
# self._broadcast_update()
def remove(self) -> None:
"""Remove the dock area."""
"""
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
parent = self.parent()
if isinstance(parent, BECMainWindow):
central_widget = parent.centralWidget()
if central_widget is self:
# Closing the parent will also close the dock area
parent.close()
return
self.close()
@@ -495,11 +519,13 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("auto")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="Waveform")
dock_area.new(widget="DarkModeButton")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
WidgetHierarchy.print_becconnector_hierarchy_from_app()
app.exec_()
sys.exit(app.exec_())

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import math
import sys
from typing import Dict, Literal, Optional, Set, Tuple, Union
@@ -34,7 +36,6 @@ class LayoutManagerWidget(QWidget):
def __init__(self, parent=None, auto_reindex=True):
super().__init__(parent)
self.setObjectName("LayoutManagerWidget")
self.layout = QGridLayout(self)
self.auto_reindex = auto_reindex

View 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/")

View File

@@ -1,74 +1,189 @@
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QMainWindow
import os
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
def __init__(self, gui_id: str = None, *args, **kwargs):
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
RPC = False
PLUGIN = False
def _dump(self):
"""Return a dictionary with informations about the application state, for use in tests"""
# TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget);
# so, a filtering based on title is applied here, but the solution is to not have those widgets
# as top-level (so for now, a window with no title does not appear in _dump() result)
def __init__(
self,
parent=None,
gui_id: str = None,
client=None,
window_title: str = "BEC",
*args,
**kwargs,
):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
# NOTE: the main window itself is excluded, since we want to dump dock areas
info = {
tlw.gui_id: {
"title": tlw.windowTitle(),
"visible": tlw.isVisible(),
"class": str(type(tlw)),
}
for tlw in QApplication.instance().topLevelWidgets()
if tlw is not self and tlw.windowTitle()
}
# Add the main window dock area
info[self.centralWidget().gui_id] = {
"title": self.windowTitle(),
"visible": self.isVisible(),
"class": str(type(self.centralWidget())),
}
return info
self.app = QApplication.instance()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
def new_dock_area(
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
) -> BECDockArea:
"""Create a new dock area.
def _init_ui(self):
Args:
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
BECDockArea: The newly created dock area.
# Set the icon
self._init_bec_icon()
# Set Menu and Status bar
self._setup_menu_bar()
# BEC Specific UI
self.display_app_id()
def _init_bec_icon(self):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def display_app_id(self):
"""
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
dock_area = BECDockArea(name=name)
dock_area.resize(dock_area.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
dock_area.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
logger.info(f"Existing dock areas: {geometry}")
if geometry is not None:
dock_area.setGeometry(*geometry)
dock_area.show()
return dock_area
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
def _get_launcher_from_qapp(self):
"""
Get the launcher from the QApplication instance.
"""
from bec_widgets.applications.launch_window import LaunchWindow
qapp = QApplication.instance()
widgets = qapp.topLevelWidgets()
widgets = [w for w in widgets if isinstance(w, LaunchWindow)]
if widgets:
return widgets[0]
return None
def _show_launcher(self):
"""
Show the launcher if it exists.
"""
launcher = self._get_launcher_from_qapp()
if launcher:
launcher.show()
launcher.activateWindow()
launcher.raise_()
def _setup_menu_bar(self):
"""
Setup the menu bar for the main window.
"""
menu_bar = self.menuBar()
##########################################
# Launch menu
launch_menu = menu_bar.addMenu("New")
open_launcher_action = QAction("Open Launcher", self)
launch_menu.addAction(open_launcher_action)
open_launcher_action.triggered.connect(self._show_launcher)
########################################
# Theme menu
theme_menu = menu_bar.addMenu("Theme")
theme_group = QActionGroup(self)
light_theme_action = QAction("Light Theme", self, checkable=True)
dark_theme_action = QAction("Dark Theme", self, checkable=True)
theme_group.addAction(light_theme_action)
theme_group.addAction(dark_theme_action)
theme_group.setExclusive(True)
theme_menu.addAction(light_theme_action)
theme_menu.addAction(dark_theme_action)
# Connect theme actions
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
# Set the default theme
theme = self.app.theme.theme
if theme == "light":
light_theme_action.setChecked(True)
elif theme == "dark":
dark_theme_action.setChecked(True)
########################################
# Help menu
help_menu = menu_bar.addMenu("Help")
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
bec_docs = QAction("BEC Docs", self)
bec_docs.setIcon(help_icon)
widgets_docs = QAction("BEC Widgets Docs", self)
widgets_docs.setIcon(help_icon)
bug_report = QAction("Bug Report", self)
bug_report.setIcon(bug_icon)
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
@SafeSlot(str)
def change_theme(self, theme: str):
apply_theme(theme)
def cleanup(self):
super().close()
central_widget = self.centralWidget()
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
children = self.findChildren(BECWidget)
for child in children:
ancestor = WidgetHierarchy._get_becwidget_ancestor(child)
if ancestor is self:
child.cleanup()
child.close()
child.deleteLater()
super().cleanup()
class UILaunchWindow(BECMainWindow):
RPC = True

View File

@@ -11,6 +11,7 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = True
def __init__(
self,
@@ -22,9 +23,7 @@ class AbortButton(BECWidget, QWidget):
scan_id=None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)

View File

@@ -11,11 +11,10 @@ class ResetButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "restart_alt"
RPC = True
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)

View File

@@ -11,10 +11,10 @@ class ResumeButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "resume"
RPC = True
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()

View File

@@ -11,10 +11,10 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
RPC = True
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
@@ -54,9 +54,20 @@ class StopButton(BECWidget, QWidget):
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
app = QApplication(sys.argv)
w = StopButton()
w.show()
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
class MyGui(QWidget):
def __init__(self):
super().__init__()
self.setLayout(QVBoxLayout())
# Create and add the StopButton to the layout
self.stop_button = StopButton()
self.layout().addWidget(self.stop_button)
# Example of how this custom GUI might be used:
app = QApplication([])
my_gui = MyGui()
my_gui.show()
sys.exit(app.exec_())

View File

@@ -8,13 +8,26 @@ from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(BECWidget, QWidget):
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
"""
Display a position within a defined range, e.g. motor limits.
"""
USER_ACCESS = [
"set_value",
"set_range",
"vertical",
"vertical.setter",
"indicator_width",
"indicator_width.setter",
"rounded_corners",
"rounded_corners.setter",
]
PLUGIN = True
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.position = 50
self.min_value = 0
self.max_value = 100
@@ -205,6 +218,12 @@ class PositionIndicator(BECWidget, QWidget):
@Slot(int)
@Slot(float)
def set_value(self, position: float):
"""
Set the position of the indicator
Args:
position: The new position of the indicator
"""
self.position = position
self.update()

View File

@@ -40,6 +40,7 @@ class DeviceUpdateUIComponents(TypedDict):
stop: QPushButton
tweak_increase: QPushButton
tweak_decrease: QPushButton
units: QLabel
class PositionerBoxBase(BECWidget, CompactPopupWidget):
@@ -47,6 +48,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
current_path = ""
ICON_NAME = "switch_right"
RPC = False
def __init__(self, parent=None, **kwargs):
"""Initialize the PositionerBox widget.
@@ -55,8 +57,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
self._dialog = None
self.get_bec_shortcuts()
@@ -84,16 +85,33 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
limit_update: Callable[[tuple[float, float]], None],
):
"""Init the device view and readback"""
if self._check_device_is_valid(device):
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
if not self._check_device_is_valid(device):
return
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
ui = self._device_ui_components(device)
if not ui.get("units"):
return
try:
egu = f"[{self.dev[device].egu()}]"
except Exception:
egu = ""
if egu:
ui["units"].setVisible(True)
ui["units"].setText(egu)
else:
ui["units"].setVisible(False)
def _stop_device(self, device: str):
"""Stop call"""

View File

@@ -31,6 +31,7 @@ class PositionerBox(PositionerBoxBase):
dimensions = (234, 224)
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
@@ -170,6 +171,7 @@ class PositionerBox(PositionerBoxBase):
"stop": self.ui.stop,
"tweak_increase": self.ui.tweak_right,
"tweak_decrease": self.ui.tweak_left,
"units": self.ui.units,
}
@SafeSlot(dict, dict)

View File

@@ -135,6 +135,29 @@
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="units">
<property name="toolTip">
<string>Motor units</string>
</property>
<property name="text">
<string></string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
@@ -203,16 +226,16 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>

View File

@@ -1,4 +1,4 @@
""" Module for a PositionerBox2D widget to control two positioner devices."""
"""Module for a PositionerBox2D widget to control two positioner devices."""
from __future__ import annotations
@@ -33,6 +33,7 @@ class PositionerBox2D(PositionerBoxBase):
ui_file = "positioner_box_2d.ui"
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
device_changed_hor = Signal(str, str)
@@ -312,6 +313,7 @@ class PositionerBox2D(PositionerBoxBase):
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_hor,
"tweak_decrease": self.ui.tweak_decrease_hor,
"units": self.ui.units_hor,
}
elif device == "vertical":
return {
@@ -324,6 +326,7 @@ class PositionerBox2D(PositionerBoxBase):
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_ver,
"tweak_decrease": self.ui.tweak_decrease_ver,
"units": self.ui.units_ver,
}
else:
raise ValueError(f"Device {device} is not represented by this UI")

View File

@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>326</width>
<height>323</height>
<width>402</width>
<height>394</height>
</rect>
</property>
<property name="windowTitle">
@@ -23,7 +23,7 @@
<property name="title">
<string>No positioner selected</string>
</property>
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0">
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
@@ -40,15 +40,38 @@
</widget>
</item>
<item>
<widget class="QLabel" name="readback_ver">
<property name="text">
<string>Position</string>
<spacer name="horizontalSpacer_18">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="units_ver">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_19">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_ver">
<property name="minimumSize">
@@ -67,20 +90,7 @@
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLineEdit" name="setpoint_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<item row="6" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QDoubleSpinBox" name="step_size_ver">
@@ -94,6 +104,29 @@
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLineEdit" name="setpoint_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="readback_ver">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@@ -132,15 +165,38 @@
</widget>
</item>
<item>
<widget class="QLabel" name="readback_hor">
<property name="text">
<string>Position</string>
<spacer name="horizontalSpacer_9">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="units_hor">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_13">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_hor">
<property name="minimumSize">
@@ -173,6 +229,16 @@
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="readback_hor">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@@ -525,16 +591,16 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>

View File

@@ -116,6 +116,13 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="units">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
@@ -218,16 +225,16 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>

View File

@@ -1,4 +1,4 @@
""" Module for a PositionerGroup widget to control a positioner device."""
"""Module for a PositionerGroup widget to control a positioner device."""
from __future__ import annotations
@@ -69,8 +69,7 @@ class PositionerGroup(BECWidget, QWidget):
Args:
parent: The parent widget.
"""
super().__init__(**kwargs)
QWidget.__init__(self, parent)
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()

View File

@@ -82,7 +82,7 @@ class DeviceInputBase(BECWidget):
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
@@ -90,7 +90,9 @@ class DeviceInputBase(BECWidget):
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.get_bec_shortcuts()
self._device_filter = []
self._readout_filter = []

View File

@@ -29,6 +29,7 @@ class DeviceSignalInputBase(BECWidget):
signal object based on the current text of the widget.
"""
RPC = False
_filter_handler = {
Kind.hinted: "include_hinted_signals",
Kind.normal: "include_normal_signals",

View File

@@ -47,8 +47,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name

View File

@@ -53,8 +53,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.completer = QCompleter(self)
self.setCompleter(self.completer)

View File

@@ -40,8 +40,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name

View File

@@ -42,8 +42,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
**kwargs,
):
self._is_valid_input = False
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)
self.setCompleter(self.completer)

View File

@@ -41,6 +41,10 @@ class ScanControlConfig(ConnectionConfig):
class ScanControl(BECWidget, QWidget):
"""
Widget to submit new scans to the queue.
"""
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
@@ -60,13 +64,11 @@ class ScanControl(BECWidget, QWidget):
default_scan: str | None = None,
**kwargs,
):
if config is None:
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._hide_add_remove_buttons = False
@@ -163,7 +165,7 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata()
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)

View File

@@ -234,7 +234,7 @@ class ScanGroupBox(QGroupBox):
continue
if default == "_empty":
default = None
widget = widget_class(arg_name=arg_name, default=default)
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
if isinstance(widget, DeviceLineEdit):
widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = ""
@@ -274,12 +274,24 @@ class ScanGroupBox(QGroupBox):
for widget in self.widgets[-len(self.inputs) :]:
if isinstance(widget, DeviceLineEdit):
self.selected_devices[widget] = ""
widget.close()
widget.deleteLater()
self.widgets = self.widgets[: -len(self.inputs)]
selected_devices_str = " ".join(self.selected_devices.values())
self.device_selected.emit(selected_devices_str.strip())
def remove_all_widget_bundles(self):
"""Remove every widget bundle from the scan control layout."""
for widget in list(self.widgets):
if isinstance(widget, DeviceLineEdit):
self.selected_devices.pop(widget, None)
widget.close()
widget.deleteLater()
self.layout.removeWidget(widget)
self.widgets.clear()
self.device_selected.emit("")
@Property(bool)
def hide_add_remove_buttons(self):
return self._hide_add_remove_buttons
@@ -348,10 +360,21 @@ class ScanGroupBox(QGroupBox):
self._set_kwarg_parameters(parameters)
def _set_arg_parameters(self, parameters: list):
while len(parameters) != len(self.widgets):
self.add_widget_bundle()
for i, parameter in enumerate(parameters):
WidgetIO.set_value(self.widgets[i], parameter)
self.remove_all_widget_bundles()
if not parameters:
return
inputs_per_bundle = len(self.inputs)
if inputs_per_bundle == 0:
return
bundles_needed = -(-len(parameters) // inputs_per_bundle)
for row in range(1, bundles_needed + 1):
self.add_input_widgets(self.inputs, row)
for i, value in enumerate(parameters):
WidgetIO.set_value(self.widgets[i], value)
def _set_kwarg_parameters(self, parameters: dict):
for widget in self.widgets:

View File

@@ -1,4 +1,4 @@
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
"""Module for DapComboBox widget class to select a DAP model from a combobox."""
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
@@ -44,8 +44,7 @@ class DapComboBox(BECWidget, QWidget):
default_fit: str | None = None,
**kwargs,
):
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)

View File

@@ -17,6 +17,7 @@ class LMFitDialog(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "monitoring"
RPC = False
# Signal to emit the currently selected fit curve_id
selected_fit = Signal(str)
# Signal to emit a move action in form of a tuple (param_name, value)
@@ -43,10 +44,8 @@ class LMFitDialog(BECWidget, QWidget):
gui_id (str): GUI ID.
ui_file (str): The UI file to be loaded.
"""
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("LMFitDialog")
self._ui_file = ui_file
self.target_widget = target_widget

View File

@@ -1,5 +1,5 @@
"""
BECConsole is a Qt widget that runs a Bash shell.
BECConsole is a Qt widget that runs a Bash shell.
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
@@ -56,12 +56,12 @@ control_keys_mapping = {
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_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
@@ -72,10 +72,10 @@ control_keys_mapping = {
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-_
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 = {
@@ -89,7 +89,7 @@ normal_keys_mapping = {
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_Down: b"\x0e",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",

View File

@@ -16,12 +16,20 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeSlot
class AdditionalMetadataTableModel(QAbstractTableModel):
class DictBackedTableModel(QAbstractTableModel):
def __init__(self, data):
"""A model to go with DictBackedTable, which represents key-value pairs
to be displayed in a TreeWidget.
Args:
data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__()
self._data: list[list[str]] = data
self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring
# see QAbstractTableModel documentation for these methods
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
) -> Any:
@@ -49,6 +57,10 @@ class AdditionalMetadataTableModel(QAbstractTableModel):
return False
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
Args:
keys (list[str]): list of keys which are forbidden."""
self._disallowed_keys = keys
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
@@ -95,16 +107,21 @@ class AdditionalMetadataTableModel(QAbstractTableModel):
return dict(self._data)
class AdditionalMetadataTable(QWidget):
class DictBackedTable(QWidget):
delete_rows = Signal(list)
def __init__(self, initial_data: list[list[str]]):
"""Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__()
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._table_model = AdditionalMetadataTableModel(initial_data)
self._table_model = DictBackedTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
@@ -126,15 +143,21 @@ class AdditionalMetadataTable(QWidget):
self.delete_rows.connect(self._table_model.delete_rows)
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
row_indices = list({r.row() for r in cells})
if row_indices:
self.delete_rows.emit(row_indices)
def dump_dict(self):
"""Get the current content of the table as a dict"""
return self._table_model.dump_dict()
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
Args:
keys (list[str]): list of keys which are forbidden."""
self._table_model.update_disallowed_keys(keys)
@@ -144,6 +167,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -1,7 +0,0 @@
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
AdditionalMetadataTableModel,
)
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
__all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]

View File

@@ -9,7 +9,7 @@ from annotated_types import Ge, Gt, Le, Lt
from bec_lib.logger import bec_logger
from pydantic_core import PydanticUndefined
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from pydantic.fields import FieldInfo
logger = bec_logger.logger

View 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.editors.scan_metadata.scan_metadata_plugin import ScanMetadataPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanMetadataPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,48 +1,21 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_lib.metadata_schema import get_metadata_schema_for_scan
from bec_qthemes import material_icon
from pydantic import Field, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QVBoxLayout,
QWidget,
)
from pydantic import Field
from qtpy.QtWidgets import QApplication, QComboBox, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
logger = bec_logger.logger
class ScanMetadata(BECWidget, QWidget):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified."""
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
class ScanMetadata(PydanticModelForm):
def __init__(
self,
parent=None,
@@ -51,118 +24,35 @@ class ScanMetadata(BECWidget, QWidget):
initial_extras: list[list[str]] | None = None,
**kwargs,
):
super().__init__(client=client, **kwargs)
QWidget.__init__(self, parent=parent)
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified.
self.set_schema(scan_name)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
self._layout.addWidget(self._required_md_box)
self._required_md_box_layout = QHBoxLayout()
self._required_md_box.set_layout(self._required_md_box_layout)
self._md_grid = QWidget()
self._required_md_box_layout.addWidget(self._md_grid)
self._grid_container = QVBoxLayout()
self._md_grid.setLayout(self._grid_container)
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
Args:
scan_name (str): The scan for which to generate a metadata form
Initial_extras (list[list[str]]): Initial data with which to populate the additional
metadata table - inner lists should be key-value pairs
"""
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
self._additional_md_box_layout.addWidget(self._additional_metadata)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.populate()
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
self.set_schema(scan_name)
self.populate()
self.validate_form()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_full_model_dict()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
def get_full_model_dict(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def set_schema(self, scan_name: str | None = None):
self._additional_metadata = DictBackedTable(initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
def populate(self):
self._clear_grid()
self._populate()
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
def _populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
self._add_griditem(field_name, info, i)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
grid = self._md_grid_layout
label = QLabel(info.title or field_name)
label.setProperty("_model_field_name", field_name)
label.setToolTip(info.description or field_name)
grid.addWidget(label, row, 0)
widget = widget_from_type(info.annotation)(info)
widget.valueChanged.connect(self.validate_form)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid = self._md_grid_layout
return {
grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
while self._md_grid_layout.count():
item = self._md_grid_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self._md_grid_layout.deleteLater()
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._md_grid.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
self._md_grid_layout = QGridLayout()
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
self.set_schema_from_scan(scan_name)
self.validate_form()
@SafeProperty(bool)
def hide_optional_metadata(self): # type: ignore
@@ -178,8 +68,25 @@ class ScanMetadata(BECWidget, QWidget):
"""
self._additional_md_box.setVisible(not hide)
def get_form_data(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
super().populate()
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
# pylint: disable=disallowed-name
from unittest.mock import patch
from bec_lib.metadata_schema import BasicScanMetadata
@@ -210,7 +117,6 @@ if __name__ == "__main__": # pragma: no cover
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
):
app = QApplication([])
w = QWidget()
selection = QComboBox()

View File

@@ -0,0 +1 @@
{'files': ['scan_metadata.py']}

View 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.editors.scan_metadata.scan_metadata import ScanMetadata
DOM_XML = """
<ui language='c++'>
<widget class='ScanMetadata' name='scan_metadata'>
</widget>
</ui>
"""
class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ScanMetadata(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(ScanMetadata.ICON_NAME)
def includeFile(self):
return "scan_metadata"
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 "ScanMetadata"
def toolTip(self):
return "Dynamically generates a form for inclusion of metadata for a scan."
def whatsThis(self):
return self.toolTip()

View File

@@ -49,8 +49,7 @@ class TextBox(BECWidget, QWidget):
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.layout = QVBoxLayout(self)
self.text_box_text_edit = QTextEdit(parent=self)
self.layout.addWidget(self.text_box_text_edit)

View File

@@ -26,8 +26,7 @@ class WebsiteWidget(BECWidget, QWidget):
def __init__(
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.website = QWebEngineView()
@@ -118,6 +117,7 @@ class WebsiteWidget(BECWidget, QWidget):
Cleanup the widget
"""
self.website.page().deleteLater()
super().cleanup()
if __name__ == "__main__":

View File

@@ -144,10 +144,10 @@ class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
RPC = True
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, *args, **kwargs)
self._ui_initialised = False
self._timer_start_num_seconds = 0

View File

@@ -27,7 +27,7 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ImageConfig(ConnectionConfig):
color_map: str = Field(
"magma", description="The colormap of the figure widget.", validate_default=True
"plasma", description="The colormap of the figure widget.", validate_default=True
)
color_bar: Literal["full", "simple"] | None = Field(
None, description="The type of the color bar."
@@ -41,6 +41,10 @@ class ImageConfig(ConnectionConfig):
class Image(PlotBase):
"""
Image widget for displaying 2D data.
"""
PLUGIN = True
RPC = True
ICON_NAME = "image"
@@ -75,12 +79,6 @@ class Image(PlotBase):
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
# ImageView Specific Settings
"color_map",
"color_map.setter",
@@ -107,8 +105,8 @@ class Image(PlotBase):
"fft.setter",
"log",
"log.setter",
"rotation",
"rotation.setter",
"num_rotation_90",
"num_rotation_90.setter",
"transpose",
"transpose.setter",
"image",
@@ -125,22 +123,21 @@ class Image(PlotBase):
popups: bool = True,
**kwargs,
):
self._main_image = ImageItem(parent_image=self)
self._color_bar = None
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
self.gui_id = config.gui_id
self._color_bar = None
self._main_image = ImageItem()
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("Image")
self._main_image = ImageItem(parent_image=self)
self.plot_item.addItem(self._main_image)
self.scan_id = None
# Default Color map to magma
self.color_map = "magma"
# Default Color map to plasma
self.color_map = "plasma"
################################################################################
# Widget Specific GUI interactions
@@ -653,21 +650,21 @@ class Image(PlotBase):
self._main_image.log = enable
@SafeProperty(int)
def rotation(self) -> int:
def num_rotation_90(self) -> int:
"""
The number of 90° rotations to apply.
The number of 90° rotations to apply counterclockwise.
"""
return self._main_image.rotation
return self._main_image.num_rotation_90
@rotation.setter
def rotation(self, value: int):
@num_rotation_90.setter
def num_rotation_90(self, value: int):
"""
Set the number of 90° rotations to apply.
Set the number of 90° rotations to apply counterclockwise.
Args:
value(int): The number of 90° rotations to apply.
"""
self._main_image.rotation = value
self._main_image.num_rotation_90 = value
@SafeProperty(bool)
def transpose(self) -> bool:
@@ -759,6 +756,19 @@ class Image(PlotBase):
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(False)
else:
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(True)
self.selection_bundle.device_combo_box.setCurrentText("")
self.selection_bundle.dim_combo_box.setCurrentText("auto")
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(False)
################################################################################
# Image Update Methods
@@ -809,6 +819,7 @@ class Image(PlotBase):
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
self._main_image.config.monitor = None
self._sync_device_selection()
########################################
# 1D updates
@@ -914,10 +925,14 @@ class Image(PlotBase):
"""
Disconnect the image update signals and clean up the image.
"""
# Main Image cleanup
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
self._main_image.config.monitor = None
self.plot_item.removeItem(self._main_image)
self._main_image = None
# Colorbar Cleanup
if self._color_bar:
if self.config.color_bar == "full":
self.cleanup_histogram_lut_item(self._color_bar)
@@ -926,6 +941,10 @@ class Image(PlotBase):
self._color_bar.deleteLater()
self._color_bar = None
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
super().cleanup()

View File

@@ -24,7 +24,7 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("magma", description="The color map of the image.")
color_map: str | None = Field("plasma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.")
v_range: tuple[float | int, float | int] | None = Field(
@@ -61,8 +61,8 @@ class ImageItem(BECConnector, pg.ImageItem):
"fft.setter",
"log",
"log.setter",
"rotation",
"rotation.setter",
"num_rotation_90",
"num_rotation_90.setter",
"transpose",
"transpose.setter",
"get_data",
@@ -82,10 +82,11 @@ class ImageItem(BECConnector, pg.ImageItem):
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
if parent_image is not None:
self.set_parent(parent_image)
else:
self.parent_image = None
super().__init__(config=config, gui_id=gui_id, **kwargs)
self.raw_data = None
self.buffer = []
@@ -94,6 +95,12 @@ class ImageItem(BECConnector, pg.ImageItem):
# Image processor will handle any setting of data
self._image_processor = ImageProcessor(config=self.config.processing)
def set_parent(self, parent: BECConnector):
self.parent_image = parent
def parent(self):
return self.parent_image
def set_data(self, data: np.ndarray):
self.raw_data = data
self._process_image()
@@ -232,13 +239,13 @@ class ImageItem(BECConnector, pg.ImageItem):
self._process_image()
@property
def rotation(self) -> Optional[int]:
def num_rotation_90(self) -> Optional[int]:
"""Get or set the number of 90° rotations to apply."""
return self.config.processing.rotation
return self.config.processing.num_rotation_90
@rotation.setter
def rotation(self, value: Optional[int]):
self.config.processing.rotation = value
@num_rotation_90.setter
def num_rotation_90(self, value: Optional[int]):
self.config.processing.num_rotation_90 = value
self._process_image()
@property
@@ -266,3 +273,7 @@ class ImageItem(BECConnector, pg.ImageItem):
self.raw_data = None
self.buffer = []
self.max_len = 0
def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
self.clear()

View File

@@ -37,7 +37,7 @@ class ProcessingConfig(BaseModel):
transpose: bool = Field(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: int = Field(
num_rotation_90: int = Field(
0, description="The rotation angle of the monitor data before displaying."
)
stats: ImageStats = Field(
@@ -140,8 +140,8 @@ class ImageProcessor(QObject):
"""Core processing logic without threading overhead."""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.num_rotation_90 is not None:
data = self.rotation(data, self.config.num_rotation_90)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:

View File

@@ -28,7 +28,9 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
# 1) Device combo box
self.device_combo_box = DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=[ReadoutPriority.ASYNC]
parent=self.target_widget,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")
@@ -38,7 +40,7 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
# 2) Dimension combo box
self.dim_combo_box = QComboBox()
self.dim_combo_box = QComboBox(parent=self.target_widget)
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")

View File

@@ -55,24 +55,24 @@ class ImageProcessingToolbarBundle(ToolbarBundle):
@SafeSlot()
def rotate_right(self):
if self.target_widget.rotation is None:
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.rotation - 1) % 4
self.target_widget.rotation = rotation
rotation = (self.target_widget.num_rotation_90 - 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.rotation is None:
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.rotation + 1) % 4
self.target_widget.rotation = rotation
rotation = (self.target_widget.num_rotation_90 + 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.rotation = 0
self.target_widget.num_rotation_90 = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)

View File

@@ -83,6 +83,10 @@ class MotorMapConfig(ConnectionConfig):
class MotorMap(PlotBase):
"""
Motor map widget for plotting motor positions in 2D including a trace of the last points.
"""
PLUGIN = True
RPC = True
ICON_NAME = "my_location"
@@ -161,9 +165,6 @@ class MotorMap(PlotBase):
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("MotorMap")
# Default values for PlotBase
self.x_grid = True
self.y_grid = True
@@ -794,6 +795,10 @@ class MotorMap(PlotBase):
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
return data
def cleanup(self):
self.motor_selection_bundle.cleanup()
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@@ -20,7 +20,6 @@ class MotorMapSettings(SettingWidget):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("MotorMapSettings")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "motor_map_settings.ui"), self)

View File

@@ -27,14 +27,18 @@ class MotorSelectionToolbarBundle(ToolbarBundle):
self.target_widget = target_widget
# Motor X
self.motor_x = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
# Motor X
self.motor_y = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_y = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
@@ -58,3 +62,9 @@ class MotorSelectionToolbarBundle(ToolbarBundle):
or motor_y != self.target_widget.config.y_motor.name
):
self.target_widget.map(motor_x, motor_y)
def cleanup(self):
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()

View File

@@ -25,7 +25,7 @@ logger = bec_logger.logger
class MultiWaveformConfig(ConnectionConfig):
color_palette: str | None = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
"plasma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: int | None = Field(
200, description="The maximum number of curves to display on the plot."
@@ -45,6 +45,10 @@ class MultiWaveformConfig(ConnectionConfig):
class MultiWaveform(PlotBase):
"""
MultiWaveform widget for displaying multiple waveforms emitted by a single signal.
"""
PLUGIN = True
RPC = True
ICON_NAME = "ssid_chart"
@@ -126,9 +130,6 @@ class MultiWaveform(PlotBase):
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("MultiWaveform")
# Scan Data
self.old_scan_id = None
self.scan_id = None
@@ -307,7 +308,7 @@ class MultiWaveform(PlotBase):
################################################################################
@SafeSlot(popup_error=True)
def plot(self, monitor: str, color_palette: str | None = "magma"):
def plot(self, monitor: str, color_palette: str | None = "plasma"):
"""
Create a plot for the given monitor.
Args:
@@ -499,3 +500,9 @@ class MultiWaveform(PlotBase):
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
def cleanup(self):
self._disconnect_monitor()
self.clear_curves()
self.monitor_selection_bundle.cleanup()
super().cleanup()

View File

@@ -20,7 +20,6 @@ class MultiWaveformControlPanel(SettingWidget):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("MultiWaveformControlPanel")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "multi_waveform_controls.ui"), self)

View File

@@ -38,7 +38,7 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False))
# Colormap Selection
self.colormap_widget = BECColorMapWidget(cmap="magma")
self.colormap_widget = BECColorMapWidget(cmap="plasma")
self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
@@ -56,3 +56,10 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
@SafeSlot(str)
def change_colormap(self, colormap: str):
self.target_widget.color_palette = colormap
def cleanup(self):
"""
Cleanup the toolbar bundle.
"""
self.monitor.close()
self.monitor.deleteLater()

View File

@@ -69,16 +69,14 @@ class PlotBase(BECWidget, QWidget):
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = False,
popups: bool = True,
**kwargs,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
# For PropertyManager identification
self.setObjectName("PlotBase")
self.get_bec_shortcuts()
# Layout Management
@@ -100,10 +98,11 @@ class PlotBase(BECWidget, QWidget):
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self._init_toolbar()
# PlotItem Addons
@@ -172,6 +171,9 @@ class PlotBase(BECWidget, QWidget):
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
# Get default viewbox state
self.mouse_bundle.get_viewbox_mode()
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
# Setting Axis Widget
@@ -794,6 +796,7 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.showAxis("top", value)
self.plot_item.showAxis("right", value)
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
@@ -813,6 +816,7 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.showAxis("bottom", value)
self.plot_item.showAxis("left", value)
self._apply_x_label()
self._apply_y_label()
self.property_changed.emit("inner_axes", value)
@@ -1018,7 +1022,7 @@ if __name__ == "__main__": # pragma: no cover:
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = DemoPlotBase()
window = PlotBase()
window.show()
sys.exit(app.exec_())

Some files were not shown because too many files have changed in this diff Show More