1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-09 18:20:55 +02:00

Compare commits

..

68 Commits

Author SHA1 Message Date
504794f86a test: cleanup to simplify testing 2024-12-03 09:04:11 +01:00
2e5ee7c5bd test: fix tests, avoid spinning up VSCode and BECConsole 2024-12-02 17:53:07 +01:00
b87cab6744 fix: clean up, run cli script, add docs 2024-12-02 17:42:11 +01:00
9ac4ce73ff fix: add cleanup to console, fix tests for user_script 2024-12-02 17:10:57 +01:00
710d7229a7 tests: add test for user script widget 2024-12-02 17:10:57 +01:00
9402ba82ff feat: add user script widget 2024-12-02 17:10:46 +01:00
semantic-release
a274a14900 1.7.0
Automatically generated by python-semantic-release
2024-12-02 15:21:52 +00:00
da579b6d21 fix(tests): add test for Console widget 2024-12-02 14:44:29 +01:00
02086aeae0 feat(console): add 'terminate' and 'send_ctrl_c' methods to Console
.terminate() ends the started process, sending SIGTERM signal.
If process is not dead after optional timeout, SIGKILL is sent.
.send_ctrl_c() sends SIGINT to the child process, and waits for
prompt until optional timeout is reached.
Timeouts raise 'TimeoutError' exception.
2024-12-02 14:44:29 +01:00
3aeb0b66fb feat(console): add "prompt" signal to inform when shell is at prompt 2024-12-02 14:44:29 +01:00
semantic-release
b4b8ae81d8 1.6.0
Automatically generated by python-semantic-release
2024-11-27 11:04:08 +00:00
da18c2ceec fix(tests): make use of BECDockArea with client mixin to start server and use it in tests
Depending on the test, auto-updates are enabled or not.
2024-11-27 11:44:03 +01:00
31d87036c9 feat: '._auto_updates_enabled' attribute can be used to activate auto updates installation in BECDockArea 2024-11-27 11:44:03 +01:00
cffcdf2923 fix: differentiate click and drag for DeviceItem, adapt tests accordingly
This fixes the blocking "QDrag.exec_()" on Linux, indeed before the
drag'n'drop operation was started with a simple click and it was
waiting for drop forever. Now there are 2 different cases, click or
drag'n'drop - the drag'n'drop test actually moves the mouse and releases
the button.
2024-11-27 11:44:03 +01:00
2fe7f5e151 fix(server): use dock area by default 2024-11-27 11:44:03 +01:00
3ba0b1daf5 feat: add rpc_id member to client objects 2024-11-27 11:44:03 +01:00
e68e2b5978 feat(client): add show()/hide() methods to "gui" object 2024-11-27 11:44:03 +01:00
daf6ea0159 feat(server): add main window, with proper gui_id derived from given id 2024-11-27 11:44:03 +01:00
f80ec33ae5 feat: add main window container widget 2024-11-27 11:44:03 +01:00
c27d058b01 fix(rpc): gui hide/show also hide/show all floating docks 2024-11-27 11:44:03 +01:00
96e255e4ef fix: do not quit automatically when last window is "closed"
Qt confuses closed and hidden
2024-11-27 11:44:03 +01:00
60292465e9 fix: no need to call inspect.signature - it can fail on methods coming from C (like Qt methods) 2024-11-27 11:44:03 +01:00
2047e484d5 feat: asynchronous .start() for GUI 2024-11-27 11:44:03 +01:00
1f71d8e5ed feat: do not take focus when GUI is loaded 2024-11-25 08:16:10 +01:00
1f60fec720 feat: add '--hide' argument to BEC GUI server 2024-11-25 08:16:10 +01:00
e9983521ed fix: add back accidentally removed variables 2024-11-25 08:16:10 +01:00
semantic-release
ed72393699 1.5.3
Automatically generated by python-semantic-release
2024-11-21 16:19:45 +00:00
e71e3b2956 fix(alignment_1d): fix imports after widget module refactor 2024-11-21 16:39:10 +01:00
6e39bdbf53 ci: fix ci syntax for package-dep-job 2024-11-21 09:13:18 +01:00
semantic-release
2e7383a10c 1.5.2
Automatically generated by python-semantic-release
2024-11-18 13:53:35 +00:00
746359b2cc fix: support for bec v3 2024-11-18 14:23:12 +01:00
semantic-release
0219f7c78a 1.5.1
Automatically generated by python-semantic-release
2024-11-14 13:30:02 +00:00
aab0229a40 refactor(widgets): widget module structure reorganised 2024-11-14 14:20:20 +01:00
7a1b8748a4 fix(plugin_utils): plugin utils are able to detect classes for plugin creation based on class attribute rather than if it is top level widget 2024-11-14 14:19:22 +01:00
semantic-release
245ebb444e 1.5.0
Automatically generated by python-semantic-release
2024-11-12 15:29:42 +00:00
0cd85ed9fa fix(crosshair): crosshair adapted for multi waveform widget 2024-11-12 16:19:42 +01:00
42d4f182f7 docs(multi_waveform): docs added 2024-11-12 16:19:42 +01:00
f3a39a69e2 feat(multi-waveform): new widget added 2024-11-12 16:19:42 +01:00
semantic-release
ec39dae273 1.4.1
Automatically generated by python-semantic-release
2024-11-12 13:46:09 +00:00
8e5c0ad8c8 fix(positioner_box): adjusted default signals 2024-11-12 14:36:38 +01:00
semantic-release
bf0b49b863 1.4.0
Automatically generated by python-semantic-release
2024-11-11 14:19:33 +00:00
11e5937ae0 fix(crosshair): label of coordinates of TextItem displays numbers in general format 2024-11-11 15:09:55 +01:00
4f31ea655c fix(crosshair): label of coordinates of TextItem is updated according to the current theme of qapp 2024-11-11 15:09:55 +01:00
64df805a9e test(crosshair): tests extended 2024-11-11 15:09:55 +01:00
035136d517 feat(crosshair): TextItem to display crosshair coordinates 2024-11-11 15:09:55 +01:00
b2eb71aae0 fix(crosshair): log is separately scaled for backend logic and for signal emit 2024-11-11 15:09:55 +01:00
semantic-release
1e6659c379 1.3.3
Automatically generated by python-semantic-release
2024-11-07 23:02:04 +00:00
5fabd4bea9 fix(scan_control): DeviceLineEdit kwargs readings changed to get name of the positioner 2024-11-07 16:47:42 +01:00
4f0693cae3 docs: update outdated text in docs 2024-11-07 12:49:36 +01:00
semantic-release
ba76d6bb86 1.3.2
Automatically generated by python-semantic-release
2024-11-05 14:53:05 +00:00
2304c9f849 fix(plot_base): legend text color is changed when changing dark-light theme 2024-11-05 10:37:53 +01:00
c6e48ec1fe build: PySide6 version fixed 6.7.2 2024-11-04 14:41:43 +01:00
semantic-release
f837129023 1.3.1
Automatically generated by python-semantic-release
2024-10-31 14:37:23 +00:00
940ee6552c fix(ophyd_kind_util): Kind enums are imported from the bec widget util class 2024-10-31 12:26:10 +01:00
semantic-release
86b60b4aed 1.3.0
Automatically generated by python-semantic-release
2024-10-30 13:19:18 +00:00
14dd8c5b29 fix(colors): extend color map validation for matplotlib and colorcet maps (if available) 2024-10-28 17:17:03 +01:00
b039933405 feat(colormap_button): colormap button with menu to select colormap filtered by the colormap type 2024-10-28 13:48:56 +01:00
semantic-release
d8c80293c7 1.2.0
Automatically generated by python-semantic-release
2024-10-25 17:17:49 +00:00
40c9fea35f feat(colors): evenly spaced color generation + new golden ratio calculation 2024-10-25 19:08:13 +02:00
5d4b86e1c6 refactor: add bec_lib version to statusbox 2024-10-25 16:12:06 +02:00
semantic-release
5681c0cbd1 1.1.0
Automatically generated by python-semantic-release
2024-10-25 08:19:34 +00:00
91959e82de refactor: do not flush selection upon receiving config update; allow widgetIO to receive kwargs to be able to use get_value to receive string instead of int for QComboBox 2024-10-24 18:09:18 +02:00
5eb15b785f refactor: allow to set selection in DeviceInput; automatic update of selection on device config update; cleanup 2024-10-24 13:38:26 +02:00
6fb20552ff refactor: cleanup, added device_signal for signal inputs 2024-10-24 09:21:32 +02:00
0350833f36 feat: add filter i/o utility class 2024-10-22 16:56:16 +02:00
acb79020d4 test(scan_control): tests added for grid_scan to ensure scan_args signal validity 2024-10-22 16:05:14 +02:00
semantic-release
9c6ba6ae73 1.0.2
Automatically generated by python-semantic-release
2024-10-22 13:34:16 +00:00
4f5448cf51 fix(scan_control): scan args signal fixed to emit list instead of hardcoded structure 2024-10-22 15:04:23 +02:00
332 changed files with 7574 additions and 1386 deletions

View File

@@ -12,6 +12,9 @@ variables:
description: ophyd_devices branch
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
@@ -31,8 +34,9 @@ include:
inputs:
stage: test
path: "."
pytest_args: "-v --random-order tests/"
exclude_packages: ""
pytest_args: "-v,--random-order,tests/unit_tests"
ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
# different stages in the pipeline
stages:

View File

@@ -1,173 +1,214 @@
# CHANGELOG
## v1.0.1 (2024-10-22)
## v1.7.0 (2024-12-02)
### Bug Fixes
* fix(waveform): added support for live_data and data access ([`7469c89`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7469c892c8076fc09e61f173df6920c551241cec))
## v1.0.0 (2024-10-18)
### Breaking
* feat!: ability to disable scatter from waveform & compatible crosshair with down sampling ([`2ab12ed`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2ab12ed60abb995abc381d9330fdcf399796d9e5))
### Bug Fixes
* fix(crosshair): downsample clear markers ([`f9a889f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f9a889fc6d380b9e587edcb465203122ea0bffc1))
## v0.119.0 (2024-10-17)
### Bug Fixes
* fix: fix syntax due to change of api for simulated devices ([`19f4e40`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/19f4e407e00ee242973ca4c3f90e4e41a4d3e315))
* fix: remove wrongly scoped test ([`a23841b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a23841b2553dc7162da943715d58275c7dc39ed9))
* fix: rename 'compact' property -> 'compact_view' ([`6982711`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6982711fea5fb8a73845ed7c0692e3ec53ef7871))
* fix: Alignment 1D update, make app window a main window (in .ui file) ([`0015f0e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0015f0e2d62adc02d3ef334e1f6dbb2d0288fec6))
* fix: set (Minimum, Fixed) size policy on Stop button ([`523cc43`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/523cc435725b10b7d59a4477a1aaa24a1f3e37a2))
- **tests**: Add test for Console widget
([`da579b6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/da579b6d213bcdf28c40c1a9e4e2535fdde824fb))
### Features
* feat: new PositionerGroup widget ([`af9655d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af9655de0c541092437accfbaa779628a2f48ccb))
- **console**: Add "prompt" signal to inform when shell is at prompt
([`3aeb0b6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3aeb0b66fbeb03d3d0ee60e108cc6b98fd9aa9b9))
* feat: add 'expand_popup' property to CompactPopupWidget
- **console**: Add 'terminate' and 'send_ctrl_c' methods to Console
([`02086ae`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02086aeae09233ec4e6ccc0e6a17f2b078d500b8))
This property tells if expand should show a popup (by default), or
if the widget should expand in-place ([`e4121a0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e4121a01cb6b8d496e630cd43bc642b994b8f310))
.terminate() ends the started process, sending SIGTERM signal. If process is not dead after optional
timeout, SIGKILL is sent. .send_ctrl_c() sends SIGINT to the child process, and waits for prompt
until optional timeout is reached. Timeouts raise 'TimeoutError' exception.
* feat: PositionerBox with a popup view ([`2615787`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/261578796f1de8ca9cab9b91659bc1484f7aa89d))
* feat: emit 'device_selected' and 'scan_axis' from scan control widget ([`0b9b1a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0b9b1a3c89a98505079f7d4078915b7bbfaa1e23))
## v1.6.0 (2024-11-27)
* feat: new 'device_selected' signals to ScanControl, ScanGroupBox, DeviceLineEdit ([`9801d27`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9801d2769eb0ee95c94ec0c011e1dac1407142ae))
### Bug Fixes
- Add back accidentally removed variables
([`e998352`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e9983521ed2a1c04af048a55ece70a1943a84313))
- Differentiate click and drag for DeviceItem, adapt tests accordingly
([`cffcdf2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cffcdf292363249bcc7efa9d130431d0bc727fda))
This fixes the blocking "QDrag.exec_()" on Linux, indeed before the drag'n'drop operation was
started with a simple click and it was waiting for drop forever. Now there are 2 different cases,
click or drag'n'drop - the drag'n'drop test actually moves the mouse and releases the button.
- Do not quit automatically when last window is "closed"
([`96e255e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96e255e4ef394eb79006a66d13e06775ae235667))
Qt confuses closed and hidden
- No need to call inspect.signature - it can fail on methods coming from C (like Qt methods)
([`6029246`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/60292465e9e52d3248ae681c68c07298b9b3ce14))
- **rpc**: Gui hide/show also hide/show all floating docks
([`c27d058`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c27d058b01fe604eccec76454e39360122e48515))
- **server**: Use dock area by default
([`2fe7f5e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2fe7f5e1510a5ea72676045e6ea3485e6b11c220))
- **tests**: Make use of BECDockArea with client mixin to start server and use it in tests
([`da18c2c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/da18c2ceecf9aeaf0e0ea9b78f4c867b27b9c314))
Depending on the test, auto-updates are enabled or not.
### Features
- '._auto_updates_enabled' attribute can be used to activate auto updates installation in
BECDockArea
([`31d8703`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/31d87036c9801e639a7ca6fc003c90e0c4edb19d))
- Add '--hide' argument to BEC GUI server
([`1f60fec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1f60fec7201ed252d7e49bf16f2166ee7f6bed6a))
- Add main window container widget
([`f80ec33`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f80ec33ae5a261dbcab901ae30f4cc802316e554))
- Add rpc_id member to client objects
([`3ba0b1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3ba0b1daf5b83da840e90fbbc063ed7b86ebe99b))
- Asynchronous .start() for GUI
([`2047e48`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2047e484d5a4b2f5ea494a1e49035b35b1bbde35))
- Do not take focus when GUI is loaded
([`1f71d8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1f71d8e5eded9952f9b34bfc427e2ff44cf5fc18))
- **client**: Add show()/hide() methods to "gui" object
([`e68e2b5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e68e2b5978339475b97555c3e20795807932fbc9))
- **server**: Add main window, with proper gui_id derived from given id
([`daf6ea0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/daf6ea0159c9ffc7b53bb7ae6b9abc16a302972c))
## v1.5.3 (2024-11-21)
### Bug Fixes
- **alignment_1d**: Fix imports after widget module refactor
([`e71e3b2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e71e3b2956feb3f3051e538432133f6e85bbd5a8))
### Continuous Integration
- Fix ci syntax for package-dep-job
([`6e39bdb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e39bdbf53b147c8ff163527b45691835ce9a2eb))
## v1.5.2 (2024-11-18)
### Bug Fixes
- Support for bec v3
([`746359b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/746359b2cc07a317473907adfcabbe5fe5d1b64c))
## v1.5.1 (2024-11-14)
### Bug Fixes
- **plugin_utils**: Plugin utils are able to detect classes for plugin creation based on class
attribute rather than if it is top level widget
([`7a1b874`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7a1b8748a433f854671ac95f2eaf4604e6b8df20))
### Refactoring
* refactor: redesign of scan selection and scan control boxes ([`a69d287`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a69d2870e2b3539739781d741b27b8599c0f4abd))
* refactor: move add/remove bundle to scan group box ([`e3d0a7b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e3d0a7bbf9918dc16eb7227a178c310256ce570d))
- **widgets**: Widget module structure reorganised
([`aab0229`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aab0229a4067ad626de919e38a5c8a2e9e7b03c2))
## v0.118.0 (2024-10-13)
## v1.5.0 (2024-11-12)
### Bug Fixes
- **crosshair**: Crosshair adapted for multi waveform widget
([`0cd85ed`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0cd85ed9fa5b67a6ecce89985cd4f54b7bbe3a4b))
### Documentation
* docs(sphinx-build): adjusted pyside verion ([`b236951`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b23695167ab969f754a058ffdccca2b40f00a008))
- **multi_waveform**: Docs added
([`42d4f18`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/42d4f182f790a97687ca3b6d0e72866070a89767))
### Features
* feat(image): image widget can take data from monitor_1d endpoint ([`9ef1d1c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9ef1d1c9ac2178d9fa2e655942208f8abbdf5c1b))
- **multi-waveform**: New widget added
([`f3a39a6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f3a39a69e29d490b3023a508ced18028c4205772))
## v0.117.1 (2024-10-11)
## v1.4.1 (2024-11-12)
### Bug Fixes
* fix(FPS): qtimer cleanup leaking ([`3a22392`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3a2239278075de7489ad10a58c31d7d89715e221))
### Unknown
* feature(vscode): added support for vscode instructions ([`f5f1f6c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f5f1f6c304b890dc162e8653005233bce4ea82e4))
* feature(vscode): support for controlling vscode from widgets ([`9238679`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/923867947f62db026ac0378c30ef62c883596058))
- **positioner_box**: Adjusted default signals
([`8e5c0ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e5c0ad8c8eff5a9308169bc663d2b7230f0ebb1))
## v0.117.0 (2024-10-11)
### Features
* feat(utils): FPS counter utility based on the viewBox updates, integrated to waveform and image widget ([`8c5ef26`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8c5ef268430d5243ac05fcbbdb6b76ad24ac5735))
### Unknown
* tests(plot_base): tests extended ([`8dc892d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8dc892df0a47ccbdd812555b7c5775a455a23ede))
## v0.116.0 (2024-10-11)
### Build System
* build: fix PySide6 to 6.7.2 ([`908dbc1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/908dbc1760da5b323722207163f00850b84fb90b))
### Features
* feat: UI changes to have top toolbar with compact popup widgets (fix issue #360) ([`499b6b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/499b6b9a12efd931b5728b519404c41a7e29e4d6))
* feat: adapt BECQueue and BECStatusBox widgets to use CompactPopupWidget ([`94ce92f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/94ce92f5b054d25ea3bb7976c1f75e14b78b9edc))
* feat: add 'CompactPopupWidget' container widget
Makes it easy to write widgets which can have a compact
representation with LED-like global state indicator,
with the possibility to display a popup dialog with more
complete UI ([`49268e3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/49268e3829406d70b09e4d88989812f5578e46f4))
## v0.115.0 (2024-10-08)
## v1.4.0 (2024-11-11)
### Bug Fixes
* fix: make Alignment1D a MainWindow as it is an application ([`c5e9ed6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c5e9ed6e422acb908e1ada32822f5d7cc256ade7))
- **crosshair**: Label of coordinates of TextItem displays numbers in general format
([`11e5937`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11e5937ae0f3c1413acd4e66878a692ebe4ef7d0))
* fix: adjust bec_qthemes dependency ([`b207e45`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b207e45a67818ee061272ce00a09fe7ea31cd1ba))
- **crosshair**: Label of coordinates of TextItem is updated according to the current theme of qapp
([`4f31ea6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f31ea655cf6190e141e6a2720a2d6da517a2b5b))
- **crosshair**: Log is separately scaled for backend logic and for signal emit
([`b2eb71a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b2eb71aae0b6a7c82158f2d150ae1e31411cfdeb))
### Features
* feat: add bec-app script to launch applications ([`8bf4842`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8bf48427884338672a8e3de3deb20439b0bfdf99))
## v0.114.0 (2024-10-02)
### Bug Fixes
* fix: prevent exception when empty string updates are coming from widget ([`04cfb1e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/04cfb1edf19437d54f07b868bcf3cfc2a35fd3bc))
* fix: use new 'scan_axis' signal, to set_x and select x axis on waveform
Fixes #361, do not try to change x axis when not permitted ([`efa2763`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/efa276358b0f5a45cce9fa84fa5f9aafaf4284f7))
### Features
* feat: new 'scan_axis' signal
Signal is emitted before "scan_started", to inform about scan positioner
and (start, stop) positions. In case of multiple bundles, the signal
is emitted multiple times. ([`f084e25`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f084e2514bc9459cccaa951b79044bc25884e738))
## v0.113.0 (2024-10-02)
### Bug Fixes
* fix: add is_log checks and functionality to plot_indicator_items ([`0f9953e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0f9953e8fdcf3f9b5a09f994c69edb6b34756df9))
### Features
* feat: add first draft for alignment_1d GUI ([`63c24f9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/63c24f97a355edaa928b6e222909252b276bcada))
* feat: add move to position button to lmfit dialog ([`281cb27`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/281cb27d8b5433e27a7ba0ca0a19e4b45b9c544f))
### Refactoring
* refactor: various minor improvements for the alignment gui ([`f554f3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f554f3c1672c4fe32968a5991dc98802556a6f3b))
* refactor: allow hiding of arg/kwarg boxes ([`efe90eb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/efe90eb163e2123a5b4d0bb59f66025a569336ad))
* refactor: add proxy to waveform to limit the dap_request frequency ([`5c74037`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5c740371d86d9b1b341bc3c4d8bdf62027aa089b))
* refactor: update dap_model also if x and y axis are selected ([`28ee385`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/28ee3856be2c47a63182b16454ece37a0ec04811))
- **crosshair**: Textitem to display crosshair coordinates
([`035136d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/035136d5171ec5f4311d15a9aa5bad2bdbc1f6cb))
### Testing
* test: add tests for scan_status_callback ([`dc0c825`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dc0c825fd594c093a24543ff803d6c6564010e92))
- **crosshair**: Tests extended
([`64df805`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64df805a9ed92bb97e580ac3bc0a1bbd2b1cb81e))
### Unknown
* feat : Add bec_signal_proxy to handle signals with option to unblock them manually. ([`1dcfeb6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1dcfeb6cfce3c69f0c5401731d4d3f9a1981b22e))
## v1.3.3 (2024-11-07)
### Bug Fixes
- **scan_control**: Devicelineedit kwargs readings changed to get name of the positioner
([`5fabd4b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5fabd4bea95bafd2352102686357cc1db80813fd))
### Documentation
- Update outdated text in docs
([`4f0693c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f0693cae34b391d75884837e1ae6353a0501868))
## v1.3.2 (2024-11-05)
### Bug Fixes
- **plot_base**: Legend text color is changed when changing dark-light theme
([`2304c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2304c9f8497c1ab1492f3e6690bb79b0464c0df8))
### Build System
- Pyside6 version fixed 6.7.2
([`c6e48ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6e48ec1fe5aaee6a7c7a6f930f1520cd439cdb2))
## v1.3.1 (2024-10-31)
### Bug Fixes
- **ophyd_kind_util**: Kind enums are imported from the bec widget util class
([`940ee65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/940ee6552c1ee8d9b4e4a74c62351f2e133ab678))
## v1.3.0 (2024-10-30)
### Bug Fixes
- **colors**: Extend color map validation for matplotlib and colorcet maps (if available)
([`14dd8c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/14dd8c5b2947c92f6643b888d71975e4e8d4ee88))
### Features
- **colormap_button**: Colormap button with menu to select colormap filtered by the colormap type
([`b039933`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b039933405e2fbe92bd81bd0748e79e8d443a741))

View File

@@ -5,36 +5,25 @@ It is a preliminary version of the GUI, which will be added to the main branch a
import os
from typing import Optional
from bec_lib.device import Positioner as BECPositioner
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, Signal
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDoubleSpinBox,
QMainWindow,
QPushButton,
QSpinBox,
)
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.toolbar import WidgetAction
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
from bec_widgets.widgets.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.positioner_group.positioner_group import PositionerGroup
from bec_widgets.widgets.stop_button.stop_button import StopButton
from bec_widgets.widgets.toggle.toggle import ToggleSwitch
from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget
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

View File

@@ -215,42 +215,18 @@
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="ScanControl" name="scan_control">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<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_kwarg_boxes" stdset="0">
<bool>false</bool>
</property>
<property name="hide_scan_control_buttons" 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>
<property name="hide_args_group" stdset="0">
<bool>false</bool>
</property>
<property name="hide_kwargs_group" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item>
@@ -440,24 +416,9 @@
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<class>DapComboBox</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECWaveformWidget</class>
<extends>QWidget</extends>
<header>bec_waveform_widget</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWidget</extends>
<header>website_widget</header>
<header>dap_combo_box</header>
</customwidget>
<customwidget>
<class>StopButton</class>
@@ -465,44 +426,59 @@
<header>stop_button</header>
</customwidget>
<customwidget>
<class>LMFitDialog</class>
<class>WebsiteWidget</class>
<extends>QWidget</extends>
<header>lm_fit_dialog</header>
</customwidget>
<customwidget>
<class>PositionerGroup</class>
<extends>QWidget</extends>
<header>positioner_group</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
<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>DeviceComboBox</class>
<extends>QComboBox</extends>
<header>device_combobox</header>
</customwidget>
<customwidget>
<class>DarkModeButton</class>
<extends>QWidget</extends>
<header>dark_mode_button</header>
</customwidget>
<customwidget>
<class>DapComboBox</class>
<class>PositionerGroup</class>
<extends>QWidget</extends>
<header>dap_combo_box</header>
<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/>
@@ -514,12 +490,12 @@
<slot>toogle_roi_select(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>529</x>
<y>728</y>
<x>1042</x>
<y>212</y>
</hint>
<hint type="destinationlabel">
<x>1099</x>
<y>96</y>
<x>1416</x>
<y>322</y>
</hint>
</hints>
</connection>
@@ -546,12 +522,12 @@
<slot>plot(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>297</x>
<y>170</y>
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1099</x>
<y>201</y>
<x>1416</x>
<y>427</y>
</hint>
</hints>
</connection>
@@ -562,12 +538,12 @@
<slot>select_y_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>297</x>
<y>170</y>
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>467</x>
<y>170</y>
<x>909</x>
<y>215</y>
</hint>
</hints>
</connection>
@@ -578,44 +554,12 @@
<slot>add_dap(QString,QString,QString)</slot>
<hints>
<hint type="sourcelabel">
<x>467</x>
<y>170</y>
</hint>
<hint type="destinationlabel">
<x>1099</x>
<y>221</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>scan_axis(QString,double,double)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>set_x(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>244</x>
<y>348</y>
</hint>
<hint type="destinationlabel">
<x>1140</x>
<y>491</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>scan_axis(QString,double,double)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_x_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>244</x>
<y>322</y>
</hint>
<hint type="destinationlabel">
<x>909</x>
<y>189</y>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>447</y>
</hint>
</hints>
</connection>
@@ -626,12 +570,44 @@
<slot>set_positioners(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>227</x>
<y>337</y>
<x>230</x>
<y>306</y>
</hint>
<hint type="destinationlabel">
<x>227</x>
<y>676</y>
<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>

View File

@@ -16,11 +16,11 @@ class Widgets(str, enum.Enum):
"""
AbortButton = "AbortButton"
BECDock = "BECDock"
BECColorMapWidget = "BECColorMapWidget"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
BECImageWidget = "BECImageWidget"
BECMotorMapWidget = "BECMotorMapWidget"
BECMultiWaveformWidget = "BECMultiWaveformWidget"
BECProgressBar = "BECProgressBar"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
@@ -38,8 +38,11 @@ class Widgets(str, enum.Enum):
ResumeButton = "ResumeButton"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
SignalComboBox = "SignalComboBox"
SignalLineEdit = "SignalLineEdit"
StopButton = "StopButton"
TextBox = "TextBox"
UserScriptWidget = "UserScriptWidget"
VSCodeEditor = "VSCodeEditor"
WebsiteWidget = "WebsiteWidget"
@@ -61,6 +64,22 @@ class AbortButton(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class BECColorMapWidget(RPCBase):
@property
@rpc_call
def colormap(self):
"""
Get the current colormap name.
"""
class BECCurve(RPCBase):
@rpc_call
@@ -450,6 +469,18 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
list: The temporary areas in the dock area.
"""
@rpc_call
def show(self):
"""
Show all windows including floating docks.
"""
@rpc_call
def hide(self):
"""
Hide all windows including floating docks.
"""
class BECFigure(RPCBase):
@property
@@ -1362,6 +1393,31 @@ class BECImageWidget(RPCBase):
"""
class BECMainWindow(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class BECMotorMap(RPCBase):
@property
@rpc_call
@@ -1561,6 +1617,436 @@ class BECMotorMapWidget(RPCBase):
"""
class BECMultiWaveform(RPCBase):
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@property
@rpc_call
def curves(self) -> collections.deque:
"""
Get the curves of the plot widget as a deque.
Returns:
deque: Deque of curves.
"""
@rpc_call
def set_monitor(self, monitor: str):
"""
Set the monitor for the plot widget.
Args:
monitor (str): The monitor to set.
"""
@rpc_call
def set_opacity(self, opacity: int):
"""
Set the opacity of the curve on the plot.
Args:
opacity(int): The opacity of the curve. 0-100.
"""
@rpc_call
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
@rpc_call
def set_curve_highlight(self, index: int):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
@rpc_call
def set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
@rpc_call
def set(self, **kwargs) -> "None":
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str", size: "int" = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str", size: "int" = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str", size: "int" = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_x_scale(self, scale: "Literal['linear', 'log']" = "linear"):
"""
Set the scale of the x-axis.
Args:
scale(Literal["linear", "log"]): Scale of the x-axis.
"""
@rpc_call
def set_y_scale(self, scale: "Literal['linear', 'log']" = "linear"):
"""
Set the scale of the y-axis.
Args:
scale(Literal["linear", "log"]): Scale of the y-axis.
"""
@rpc_call
def set_x_lim(self, *args) -> "None":
"""
Set the limits of the x-axis. This method can accept either two separate arguments
for the minimum and maximum x-axis values, or a single tuple containing both limits.
Usage:
set_x_lim(x_min, x_max)
set_x_lim((x_min, x_max))
Args:
*args: A variable number of arguments. Can be two integers (x_min and x_max)
or a single tuple with two integers.
"""
@rpc_call
def set_y_lim(self, *args) -> "None":
"""
Set the limits of the y-axis. This method can accept either two separate arguments
for the minimum and maximum y-axis values, or a single tuple containing both limits.
Usage:
set_y_lim(y_min, y_max)
set_y_lim((y_min, y_max))
Args:
*args: A variable number of arguments. Can be two integers (y_min and y_max)
or a single tuple with two integers.
"""
@rpc_call
def set_grid(self, x: "bool" = False, y: "bool" = False):
"""
Set the grid of the plot widget.
Args:
x(bool): Show grid on the x-axis.
y(bool): Show grid on the y-axis.
"""
@rpc_call
def set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
@rpc_call
def enable_fps_monitor(self, enable: "bool" = True):
"""
Enable the FPS monitor.
Args:
enable(bool): True to enable, False to disable.
"""
@rpc_call
def lock_aspect_ratio(self, lock):
"""
Lock aspect ratio.
Args:
lock(bool): True to lock, False to unlock.
"""
@rpc_call
def export(self):
"""
Show the Export Dialog of the plot widget.
"""
@rpc_call
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
@rpc_call
def remove(self):
"""
Remove the plot widget from the figure.
"""
class BECMultiWaveformWidget(RPCBase):
@property
@rpc_call
def curves(self) -> list[pyqtgraph.graphicsItems.PlotDataItem.PlotDataItem]:
"""
Get the curves of the plot widget as a list
Returns:
list: List of curves.
"""
@rpc_call
def set_monitor(self, monitor: str) -> None:
"""
Set the monitor of the plot widget.
Args:
monitor(str): The monitor to set.
"""
@rpc_call
def set_curve_highlight(self, index: int) -> None:
"""
Set the curve highlight of the plot widget by index
Args:
index(int): The index of the curve to highlight.
"""
@rpc_call
def set_opacity(self, opacity: int) -> None:
"""
Set the opacity of the plot widget.
Args:
opacity(int): The opacity to set.
"""
@rpc_call
def set_curve_limit(self, curve_limit: int) -> None:
"""
Set the maximum number of traces to display on the plot widget.
Args:
curve_limit(int): The maximum number of traces to display.
"""
@rpc_call
def set_buffer_flush(self, flush_buffer: bool) -> None:
"""
Set the buffer flush property of the plot widget.
Args:
flush_buffer(bool): True to flush the buffer, False to not flush the buffer.
"""
@rpc_call
def set_highlight_last_curve(self, enable: bool) -> None:
"""
Enable or disable highlighting of the last curve.
Args:
enable(bool): True to enable highlighting of the last curve, False to disable.
"""
@rpc_call
def set_colormap(self, colormap: str) -> None:
"""
Set the colormap of the plot widget.
Args:
colormap(str): The colormap to set.
"""
@rpc_call
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): The title to set.
"""
@rpc_call
def set_x_label(self, x_label: str):
"""
Set the x-axis label of the plot widget.
Args:
x_label(str): The x-axis label to set.
"""
@rpc_call
def set_y_label(self, y_label: str):
"""
Set the y-axis label of the plot widget.
Args:
y_label(str): The y-axis label to set.
"""
@rpc_call
def set_x_scale(self, x_scale: Literal["linear", "log"]):
"""
Set the x-axis scale of the plot widget.
Args:
x_scale(str): The x-axis scale to set.
"""
@rpc_call
def set_y_scale(self, y_scale: Literal["linear", "log"]):
"""
Set the y-axis scale of the plot widget.
Args:
y_scale(str): The y-axis scale to set.
"""
@rpc_call
def set_x_lim(self, x_lim: tuple):
"""
Set x-axis limits of the plot widget.
Args:
x_lim(tuple): The x-axis limits to set.
"""
@rpc_call
def set_y_lim(self, y_lim: tuple):
"""
Set y-axis limits of the plot widget.
Args:
y_lim(tuple): The y-axis limits to set.
"""
@rpc_call
def set_grid(self, x_grid: bool, y_grid: bool):
"""
Set the grid of the plot widget.
Args:
x_grid(bool): True to enable the x-grid, False to disable.
y_grid(bool): True to enable the y-grid, False to disable.
"""
@rpc_call
def set_colormap(self, colormap: str) -> None:
"""
Set the colormap of the plot widget.
Args:
colormap(str): The colormap to set.
"""
@rpc_call
def enable_fps_monitor(self, enabled: bool):
"""
Enable or disable the FPS monitor
Args:
enabled(bool): True to enable the FPS monitor, False to disable.
"""
@rpc_call
def lock_aspect_ratio(self, lock: bool):
"""
Lock the aspect ratio of the plot widget.
Args:
lock(bool): True to lock the aspect ratio, False to unlock.
"""
@rpc_call
def export(self):
"""
Export the plot widget.
"""
class BECPlotBase(RPCBase):
@property
@rpc_call
@@ -1796,6 +2282,13 @@ class BECQueue(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class BECStatusBox(RPCBase):
@property
@@ -1814,6 +2307,13 @@ class BECStatusBox(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class BECWaveform(RPCBase):
@property
@@ -2536,6 +3036,13 @@ class DeviceBrowser(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class DeviceComboBox(RPCBase):
@property
@@ -2554,6 +3061,13 @@ class DeviceComboBox(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class DeviceInputBase(RPCBase):
@property
@@ -2572,6 +3086,13 @@ class DeviceInputBase(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class DeviceLineEdit(RPCBase):
@property
@@ -2590,6 +3111,38 @@ class DeviceLineEdit(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class DeviceSignalInputBase(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class LMFitDialog(RPCBase):
@property
@@ -2608,6 +3161,13 @@ class LMFitDialog(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class PositionIndicator(RPCBase):
@rpc_call
@@ -2670,6 +3230,16 @@ class PositionerControlLine(RPCBase):
"""
class PositionerGroup(RPCBase):
@rpc_call
def set_positioners(self, device_names: "str"):
"""
Redraw grid with positioners from device_names string
Device names must be separated by space
"""
class ResetButton(RPCBase):
@property
@rpc_call
@@ -2687,6 +3257,13 @@ class ResetButton(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class ResumeButton(RPCBase):
@property
@@ -2705,6 +3282,13 @@ class ResumeButton(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class Ring(RPCBase):
@rpc_call
@@ -3002,6 +3586,63 @@ class ScanControl(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class SignalComboBox(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class SignalLineEdit(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class StopButton(RPCBase):
@property
@@ -3020,6 +3661,13 @@ class StopButton(RPCBase):
Get all registered RPC objects.
"""
@property
@rpc_call
def _rpc_id(self) -> "str":
"""
Get the RPC ID of the widget.
"""
class TextBox(RPCBase):
@rpc_call
@@ -3041,6 +3689,9 @@ class TextBox(RPCBase):
"""
class UserScriptWidget(RPCBase): ...
class VSCodeEditor(RPCBase): ...

View File

@@ -92,11 +92,11 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
if config:
if isinstance(config, dict):
config = json.dumps(config)
command.extend(["--config", config])
command.extend(["--config", str(config)])
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
@@ -126,15 +126,36 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
return process, process_output_processing_thread
class RepeatTimer(threading.Timer):
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
class BECGuiClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._auto_updates_enabled = True
self._auto_updates = None
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
@property
def auto_updates(self):
if self._auto_updates_enabled:
self._gui_started_event.wait()
return self._auto_updates
def shutdown_auto_updates(self):
if self._auto_updates_enabled:
if self._auto_updates is not None:
self._auto_updates.shutdown()
self._auto_updates = None
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
@@ -180,38 +201,80 @@ class BECGuiClientMixin:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
self.auto_updates.msg_queue.put(msg)
if self._auto_updates_enabled:
self.auto_updates.msg_queue.put(msg)
def show(self) -> None:
def _gui_post_startup(self):
if self._auto_updates_enabled:
if self._auto_updates is None:
auto_updates = self._get_update_script()
if auto_updates is None:
AutoUpdates.create_default_dock = True
AutoUpdates.enabled = True
auto_updates = AutoUpdates(gui=self)
if auto_updates.create_default_dock:
auto_updates.start_default_dock()
# fig = auto_updates.get_default_figure()
self._auto_updates = auto_updates
self._gui_started_event.set()
self.show_all()
def start_server(self, wait=False) -> None:
"""
Show the figure.
Start the GUI server, and execute callback when it is launched
"""
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._gui_started_event.clear()
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
logger.success(f"GUI started with id: {self._gui_id}")
def gui_started_callback(callback):
try:
if callable(callback):
callback()
finally:
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
1, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
if wait:
self._gui_started_event.wait()
def show_all(self):
self._gui_started_event.wait()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
def hide_all(self):
self._gui_started_event.wait()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("hide")
def close(self) -> None:
"""
Close the gui window.
"""
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
self._gui_started_timer.join()
if self._process is None:
return
self._client.shutdown()
if self._process:
logger.success("Stopping GUI...")
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
if self.auto_updates is not None:
self.auto_updates.shutdown()
self.shutdown_auto_updates()
class RPCResponseTimeoutError(Exception):

View File

@@ -11,7 +11,7 @@ import isort
from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -175,7 +175,7 @@ def main():
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets")
rpc_classes = get_custom_classes("bec_widgets")
generator = ClientGenerator()
generator.generate_client(rpc_classes)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from threading import Lock
from weakref import WeakValueDictionary

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector
@@ -26,10 +28,10 @@ class RPCWidgetHandler:
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
clss = get_custom_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import inspect
import json
import signal
import sys
@@ -11,14 +10,14 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from qtpy.QtCore import Qt, QTimer
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
@@ -96,11 +95,7 @@ class BECWidgetsCLIServer:
setattr(obj, method, args[0])
res = None
else:
sig = inspect.signature(method_obj)
if sig.parameters:
res = method_obj(*args, **kwargs)
else:
res = method_obj()
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
@@ -182,7 +177,7 @@ def main():
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow
from qtpy.QtWidgets import QApplication
import bec_widgets
@@ -200,6 +195,7 @@ def main():
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument("--config", type=str, help="Config file or config string.")
parser.add_argument("--hide", action="store_true", help="Hide on startup")
args = parser.parse_args()
@@ -212,11 +208,12 @@ def main():
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECFigure
gui_class = BECDockArea
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
@@ -226,15 +223,19 @@ def main():
)
app.setWindowIcon(icon)
win = QMainWindow()
server = _start_server(args.id, gui_class, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC Widgets")
server = _start_server(args.id, gui_class, args.config)
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
if not args.hide:
win.show()
app.aboutToQuit.connect(server.shutdown)

View File

@@ -3,8 +3,6 @@ import os
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
@@ -17,9 +15,9 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -56,6 +54,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# "cm": self.colormap,
"im": self.im,
"mm": self.mm,
"mw": self.mw,
}
)
@@ -167,9 +166,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.im.image("waveform", "1d")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
self.wf.plot(x_name="samx", y_name="bpm3a")
self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
# self.wf.plot(x_name="samx", y_name="bpm3a")
# self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
# self.bar.set_diameter(200)
@@ -210,6 +211,7 @@ if __name__ == "__main__": # pragma: no cover
win = JupyterConsoleWindow()
win.show()
win.resize(1200, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -13,7 +13,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class PaletteViewer(BECWidget, QWidget):

226
bec_widgets/tests/utils.py Normal file
View File

@@ -0,0 +1,226 @@
from unittest.mock import MagicMock
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
class FakeDevice(BECDevice):
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
class FakePositioner(BECPositioner):
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name=name)
# self.limits = limits if limits is not None else [0.0, 0.0]
self.read_value = read_value
self.setpoint_value = read_value
self.motor_is_moving_value = 0
self._enabled = enabled
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
}
}
self.signals = {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.setpoint_value},
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.read_value = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self):
return self.signals
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devives(self, devices: list):
for device in devices:
self.devices[device.name] = device
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]

View File

@@ -69,7 +69,7 @@ class Worker(QRunnable):
class BECConnector:
"""Connection mixin class to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):

View File

@@ -107,9 +107,98 @@ class Colors:
angles.append(angle)
return angles
@staticmethod
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
"""
Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Args:
theme(str): The theme to be applied.
offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
tuple: Tuple of min_pos and max_pos.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if offset < 0 or offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
if theme is None:
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme.theme
if theme == "light":
min_pos = 0.0
max_pos = 1 - offset
else:
min_pos = 0.0 + offset
max_pos = 1.0
return min_pos, max_pos
@staticmethod
def evenly_spaced_colors(
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["light", "dark"] | None = None,
) -> list:
"""
Extract `num` colors from the specified colormap, evenly spaced along its range,
and return them in the specified format.
Args:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
theme (Literal['light', 'dark'] | None): The theme to be applied. Overrides the QApplication theme if specified.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If theme_offset is not between 0 and 1.
"""
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = pg.colormap.get(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
if num == 1:
positions = np.array([(min_pos + max_pos) / 2])
else:
positions = np.linspace(min_pos, max_pos, num)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def golden_angle_color(
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor"
colormap: str,
num: int,
format: Literal["QColor", "HEX", "RGB"] = "QColor",
theme_offset=0.2,
theme: Literal["dark", "light"] | None = None,
) -> list:
"""
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
@@ -118,45 +207,39 @@ class Colors:
colormap (str): Name of the colormap.
num (int): Number of requested colors.
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
Returns:
list: List of colors in the specified format.
Raises:
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
ValueError: If theme_offset is not between 0 and 1.
"""
cmap = pg.colormap.get(colormap)
cmap_colors = cmap.getColors(mode="float")
if num > len(cmap_colors):
raise ValueError(
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
)
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
ii = 0
while len(colors) < num:
color_index = int(color_selection[ii])
color = cmap_colors[color_index]
app = QApplication.instance()
if hasattr(app, "theme") and app.theme.theme == "light":
background = 255
else:
background = 0
if np.abs(np.mean(color[:3] * 255) - background) < 50:
ii += 1
continue
cmap = pg.colormap.get(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions within the acceptable range
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
colors.append(QColor.fromRgbF(*color).name())
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
colors.append(tuple((np.array(color) * 255).astype(int)))
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
colors.append(QColor.fromRgbF(*color))
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
ii += 1
return colors
return color_list
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
@@ -385,7 +468,7 @@ class Colors:
return color
@staticmethod
def validate_color_map(color_map: str) -> str:
def validate_color_map(color_map: str, return_error: bool = True) -> str | bool:
"""
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
@@ -393,13 +476,24 @@ class Colors:
color_map(str): The colormap to be validated.
Returns:
str: The validated colormap.
str: The validated colormap, if colormap is valid.
bool: False, if colormap is invalid.
Raises:
PydanticCustomError: If colormap is invalid.
"""
available_colormaps = pg.colormap.listMaps()
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
if color_map not in available_colormaps:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
if return_error:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
return color_map

View File

@@ -2,29 +2,34 @@ from collections import defaultdict
import numpy as np
import pyqtgraph as pg
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject, Qt
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
class NonDownsamplingScatterPlotItem(pg.ScatterPlotItem):
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
pass
def setClipToView(self, state):
pass
def setAlpha(self, *args, **kwargs):
pass
class Crosshair(QObject):
positionChanged = pyqtSignal(tuple)
positionClicked = pyqtSignal(tuple)
# QT Position of mouse cursor
positionChanged = Signal(tuple)
positionClicked = Signal(tuple)
# Plain crosshair position signals mapped to real coordinates
crosshairChanged = Signal(tuple)
crosshairClicked = Signal(tuple)
# Signal for 1D plot
coordinatesChanged1D = pyqtSignal(tuple)
coordinatesClicked1D = pyqtSignal(tuple)
coordinatesChanged1D = Signal(tuple)
coordinatesClicked1D = Signal(tuple)
# Signal for 2D plot
coordinatesChanged2D = pyqtSignal(tuple)
coordinatesClicked2D = pyqtSignal(tuple)
coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
"""
@@ -45,52 +50,147 @@ class Crosshair(QObject):
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
self.h_line.skip_auto_range = True
# Add custom attribute to identify crosshair lines
self.v_line.is_crosshair = True
self.h_line.is_crosshair = True
self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True)
# Initialize highlighted curve in a case of multiple curves
self.highlighted_curve_index = None
# Add TextItem to display coordinates
self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
self.coord_label.setVisible(False) # Hide initially
self.coord_label.skip_auto_range = True
self.plot_item.addItem(self.coord_label)
# Signals to connect
self.proxy = pg.SignalProxy(
self.plot_item.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
)
self.positionChanged.connect(self.update_coord_label)
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
# Connect signals from pyqtgraph right click menu
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.downsampleSpin.valueChanged.connect(self.clear_markers)
# Initialize markers
self.items = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_2d = None
self.update_markers()
self.check_log()
self.check_derivatives()
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@Slot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
def apply_theme(self, theme: str):
"""Apply the theme to the plot."""
if theme == "dark":
text_color = "w"
label_bg_color = (50, 50, 50, 150)
elif theme == "light":
text_color = "k"
label_bg_color = (240, 240, 240, 150)
else:
text_color = "w"
label_bg_color = (50, 50, 50, 150)
self.coord_label.setColor(text_color)
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
Args:
curve_index(int): The index of curve to highlight
"""
self.highlighted_curve_index = curve_index
self.clear_markers()
self.update_markers()
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
# Create new markers
for item in self.plot_item.items:
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
else:
# Handle all curves
self.items = self.plot_item.items
# Create or update markers
for item in self.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
if item.name() in self.marker_moved_1d:
continue
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
marker_moved = NonDownsamplingScatterPlotItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_moved.skip_auto_range = True
self.marker_moved_1d[item.name()] = marker_moved
self.plot_item.addItem(marker_moved)
# Create glowing effect markers for clicked events
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = NonDownsamplingScatterPlotItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
name = item.name() or str(id(item))
if name in self.marker_moved_1d:
# Update existing markers
marker_moved = self.marker_moved_1d[name]
marker_moved.setPen(pg.mkPen(color))
# Update clicked markers' brushes
for marker_clicked in self.marker_clicked_1d[name]:
alpha = marker_clicked.opts["brush"].color().alpha()
marker_clicked.setBrush(
pg.mkBrush(color.red(), color.green(), color.blue(), alpha)
)
# Update z-values
marker_moved.setZValue(item.zValue() + 1)
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setZValue(item.zValue() + 1)
else:
# Create new markers
marker_moved = CrosshairScatterItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_clicked.skip_auto_range = True
self.marker_clicked_1d[item.name()] = marker_clicked
self.plot_item.addItem(marker_clicked)
marker_moved.skip_auto_range = True
marker_moved.is_crosshair = True
self.marker_moved_1d[name] = marker_moved
self.plot_item.addItem(marker_moved)
# Set marker z-value higher than the curve
marker_moved.setZValue(item.zValue() + 1)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = CrosshairScatterItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_clicked.skip_auto_range = True
marker_clicked.is_crosshair = True
self.plot_item.addItem(marker_clicked)
marker_clicked.setZValue(item.zValue() + 1)
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
continue
@@ -112,12 +212,11 @@ class Crosshair(QObject):
"""
y_values = defaultdict(list)
x_values = defaultdict(list)
image_2d = None
# Iterate through items in the plot
for item in self.plot_item.items:
for item in self.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
name = item.name()
name = item.name() or str(id(item))
plot_data = item._getDisplayDataset()
if plot_data is None:
continue
@@ -138,7 +237,7 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor
image_2d = item.image
# clip the x and y values to the image dimensions to avoid out of bounds errors
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
@@ -151,21 +250,34 @@ class Crosshair(QObject):
return None, None
def closest_x_y_value(self, input_value: float, list_x: list, list_y: list) -> tuple:
def closest_x_y_value(self, input_x: float, list_x: list, list_y: list) -> tuple:
"""
Find the closest x and y value to the input value.
Args:
input_value (float): Input value
input_x (float): Input value
list_x (list): List of x values
list_y (list): List of y values
Returns:
tuple: Closest x and y value
"""
arr = np.asarray(list_x)
i = (np.abs(arr - input_value)).argmin()
return list_x[i], list_y[i]
# Convert lists to NumPy arrays
arr_x = np.asarray(list_x)
# Get the indices where x is not NaN
valid_indices = ~np.isnan(arr_x)
# Filter x array to exclude NaN values
filtered_x = arr_x[valid_indices]
# Find the index of the closest value in the filtered x array
closest_index = np.abs(filtered_x - input_x).argmin()
# Map back to the original index in the list_x and list_y arrays
original_index = np.where(valid_indices)[0][closest_index]
return list_x[original_index], list_y[original_index]
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
@@ -175,17 +287,15 @@ class Crosshair(QObject):
"""
pos = event[0]
self.update_markers()
self.positionChanged.emit((pos.x(), pos.y()))
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
self.v_line.setPos(mouse_point.x())
self.h_line.setPos(mouse_point.y())
x, y = mouse_point.x(), mouse_point.y()
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
@@ -195,14 +305,19 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
for item in self.plot_item.items:
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name()
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
@@ -229,12 +344,10 @@ class Crosshair(QObject):
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
x, y = mouse_point.x(), mouse_point.y()
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairClicked.emit((scaled_x, scaled_y))
self.positionClicked.emit((x, y))
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
@@ -245,14 +358,20 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
for item in self.plot_item.items:
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name()
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_clicked_1d[name].setData([x], [y])
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
@@ -268,14 +387,47 @@ class Crosshair(QObject):
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
marker.clear()
for marker in self.marker_clicked_1d.values():
marker.clear()
self.plot_item.removeItem(marker)
for markers in self.marker_clicked_1d.values():
for marker in markers:
self.plot_item.removeItem(marker)
self.marker_moved_1d.clear()
self.marker_clicked_1d.clear()
def scale_emitted_coordinates(self, x, y):
"""Scales the emitted coordinates if the axes are in log scale.
Args:
x (float): The x-coordinate
y (float): The y-coordinate
Returns:
tuple: The scaled x and y coordinates
"""
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
return x, y
def update_coord_label(self, pos: tuple):
"""Updates the coordinate label based on the crosshair position and axis scales.
Args:
pos (tuple): The (x, y) position of the crosshair.
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
# Update coordinate label
self.coord_label.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
def check_log(self):
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
self.is_log_x = self.plot_item.axes["bottom"]["item"].logMode
self.is_log_y = self.plot_item.axes["left"]["item"].logMode
self.clear_markers()
def check_derivatives(self):
@@ -284,6 +436,8 @@ class Crosshair(QObject):
self.clear_markers()
def cleanup(self):
self.v_line.deleteLater()
self.h_line.deleteLater()
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()

View File

@@ -0,0 +1,156 @@
"""Module for handling filter I/O operations in BEC Widgets for input fields.
These operations include filtering device/signal names and/or device types.
"""
from abc import ABC, abstractmethod
from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
logger = bec_logger.logger
class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list) -> None:
"""Set the filtered_selection for the widget
Args:
selection (list): Filtered selection of items
"""
@abstractmethod
def check_input(self, widget, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget: Widget instance
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list): Filtered selection of items
"""
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
widget.completer.setModel(QStringListModel(selection, widget))
def check_input(self, widget: QLineEdit, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget (QLineEdit): The QLineEdit widget
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
model = widget.completer.model()
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list): Filtered selection of items
"""
widget.clear()
widget.addItems(selection)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
Args:
widget (QComboBox): The QComboBox widget
text (str): Input text
Returns:
bool: True if the input text is in the filtered selection
"""
return text in [widget.itemText(i) for i in range(widget.count())]
class FilterIO:
"""Public interface to set filters for input widgets.
It supports the list of widgets stored in class attribute _handlers.
"""
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list, ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection(list): List of filtered selection items.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().set_selection(widget=widget, selection=selection)
if not ignore_errors:
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
return None
@staticmethod
def check_input(widget, text: str, ignore_errors=True):
"""
Check if the input text is in the filtered selection.
Args:
widget: Widget instance.
text(str): Input text.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
Returns:
bool: True if the input text is in the filtered selection.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().check_input(widget=widget, text=text)
if not ignore_errors:
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
return None
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in FilterIO._handlers:
return FilterIO._handlers[base]
return None

View File

@@ -143,7 +143,7 @@ class DesignerPluginGenerator:
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
from bec_widgets.widgets.utility.spinner import SpinnerWidget
generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False)

View File

@@ -0,0 +1,26 @@
from enum import IntFlag
try:
from enum import KEEP
class IFBase(IntFlag, boundary=KEEP): ...
except ImportError:
IFBase = IntFlag
class Kind(IFBase):
"""
This is used in the .kind attribute of all OphydObj (Signals, Devices).
A Device examines its components' .kind atttribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`.
"""
omitted = 0b000
normal = 0b001
config = 0b010
hinted = 0b101 # Notice that bool(hinted & normal) is True.

View File

@@ -53,7 +53,7 @@ class BECClassInfo:
obj: type
is_connector: bool = False
is_widget: bool = False
is_top_level: bool = False
is_plugin: bool = False
class BECClassContainer:
@@ -88,14 +88,14 @@ class BECClassContainer:
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_top_level]
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property
def widgets(self):
@@ -109,10 +109,17 @@ class BECClassContainer:
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""
Get all classes.
"""
return [info.obj for info in self.collection]
def get_rpc_classes(repo_name: str) -> BECClassContainer:
def get_custom_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
@@ -153,6 +160,8 @@ def get_rpc_classes(repo_name: str) -> BECClassContainer:
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
return collection

View File

@@ -4,7 +4,7 @@ from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.utils.plugin_utils import get_custom_classes
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
@@ -30,7 +30,7 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
widgets = get_rpc_classes("bec_widgets").top_level_classes
widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}

View File

@@ -1,5 +1,6 @@
# pylint: disable=no-name-in-module
from abc import ABC, abstractmethod
from typing import Literal
from qtpy.QtWidgets import (
QApplication,
@@ -20,7 +21,7 @@ class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@abstractmethod
def get_value(self, widget: QWidget):
def get_value(self, widget: QWidget, **kwargs):
"""Retrieve value from the widget instance."""
@abstractmethod
@@ -31,7 +32,7 @@ class WidgetHandler(ABC):
class LineEditHandler(WidgetHandler):
"""Handler for QLineEdit widgets."""
def get_value(self, widget: QLineEdit) -> str:
def get_value(self, widget: QLineEdit, **kwargs) -> str:
return widget.text()
def set_value(self, widget: QLineEdit, value: str) -> None:
@@ -41,7 +42,9 @@ class LineEditHandler(WidgetHandler):
class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets."""
def get_value(self, widget: QComboBox) -> int:
def get_value(self, widget: QComboBox, as_string: bool = False, **kwargs) -> int | str:
if as_string is True:
return widget.currentText()
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int | str) -> None:
@@ -54,7 +57,7 @@ class ComboBoxHandler(WidgetHandler):
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
def get_value(self, widget: QTableWidget) -> list:
def get_value(self, widget: QTableWidget, **kwargs) -> list:
return [
[
widget.item(row, col).text() if widget.item(row, col) else ""
@@ -73,7 +76,7 @@ class TableWidgetHandler(WidgetHandler):
class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
def get_value(self, widget):
def get_value(self, widget, **kwargs):
return widget.value()
def set_value(self, widget, value):
@@ -83,7 +86,7 @@ class SpinBoxHandler(WidgetHandler):
class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget):
def get_value(self, widget, **kwargs):
return widget.isChecked()
def set_value(self, widget, value):
@@ -93,7 +96,7 @@ class CheckBoxHandler(WidgetHandler):
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
def get_value(self, widget):
def get_value(self, widget, **kwargs):
return widget.text()
def set_value(self, widget, value):
@@ -114,7 +117,7 @@ class WidgetIO:
}
@staticmethod
def get_value(widget, ignore_errors=False):
def get_value(widget, ignore_errors=False, **kwargs):
"""
Retrieve value from the widget instance.
@@ -124,7 +127,7 @@ class WidgetIO:
"""
handler_class = WidgetIO._find_handler(widget)
if handler_class:
return handler_class().get_value(widget) # Instantiate the handler
return handler_class().get_value(widget, **kwargs) # Instantiate the handler
if not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
return None

View File

@@ -1,123 +0,0 @@
from __future__ import annotations
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
class DeviceInputConfig(ConnectionConfig):
device_filter: str | list[str] | None = None
default: str | None = None
arg_name: str | None = None
class DeviceInputBase(BECWidget):
"""
Mixin class for device input widgets. This class provides methods to get the device list and device object based
on the current text of the widget.
"""
def __init__(self, client=None, config=None, gui_id=None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
self._device_filter = None
self._devices = []
@property
def devices(self) -> list[str]:
"""
Get the list of devices.
Returns:
list[str]: List of devices.
"""
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
self._devices = value
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
self._device_filter = device_filter
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
self.validate_device(default_device)
self.config.default = default_device
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
"""
Get the list of device names based on the filter of current BEC client.
Args:
filter(str|None): Class name filter to apply on the device list.
Returns:
devices(list[str]): List of device names.
"""
all_devices = self.dev.enabled_devices
if filter is not None:
self.validate_device_filter(filter)
if isinstance(filter, str):
filter = [filter]
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
else:
devices = [device.name for device in all_devices]
return devices
def get_available_filters(self):
"""
Get the available device classes which can be used as filters.
"""
all_devices = self.dev.enabled_devices
filters = {device.__class__.__name__ for device in all_devices}
return filters
def validate_device_filter(self, filter: str | list[str]) -> None:
"""
Validate the device filter if the class name is present in the current BEC instance.
Args:
filter(str|list[str]): Class name to use as a device filter.
"""
if isinstance(filter, str):
filter = [filter]
available_filters = self.get_available_filters()
for f in filter:
if f not in available_filters:
raise ValueError(f"Device filter {f} is not valid.")
def validate_device(self, device: str) -> None:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device not in self.get_device_list(self.config.device_filter):
raise ValueError(f"Device {device} is not valid.")

View File

@@ -18,17 +18,18 @@ from bec_widgets.qt_utils.toolbar import (
)
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
from bec_widgets.widgets.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.image.image_widget import BECImageWidget
from bec_widgets.widgets.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.scan_control.scan_control import ScanControl
from bec_widgets.widgets.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class DockAreaConfig(ConnectionConfig):
@@ -39,6 +40,7 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [
"_config_dict",
"panels",
@@ -51,6 +53,8 @@ class BECDockArea(BECWidget, QWidget):
"attach_all",
"_get_all_rpc",
"temp_areas",
"show",
"hide",
]
def __init__(
@@ -85,6 +89,11 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=BECMultiWaveformWidget.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
),
@@ -154,6 +163,9 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
)
@@ -402,6 +414,18 @@ class BECDockArea(BECWidget, QWidget):
self.cleanup()
super().close()
def show(self):
"""Show all windows including floating docks."""
super().show()
for docks in self.panels.values():
docks.window().show()
def hide(self):
"""Hide all windows including floating docks."""
super().hide()
for docks in self.panels.values():
docks.window().hide()
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication

View File

@@ -6,7 +6,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dock import BECDockArea
from bec_widgets.widgets.containers.dock import BECDockArea
DOM_XML = """
<ui language='c++'>

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.dock.dock_area_plugin import BECDockAreaPlugin
from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())

View File

@@ -16,10 +16,20 @@ from typeguard import typechecked
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import (
BECMotorMap,
MotorMapConfig,
)
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
BECMultiWaveform,
BECMultiWaveformConfig,
)
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import (
BECWaveform,
Waveform1DConfig,
)
logger = bec_logger.logger
@@ -64,6 +74,7 @@ class WidgetHandler:
"BECWaveform": (BECWaveform, Waveform1DConfig),
"BECImageShow": (BECImageShow, ImageConfig),
"BECMotorMap": (BECMotorMap, MotorMapConfig),
"BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
}
def create_widget(
@@ -134,8 +145,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
"BECWaveform": BECWaveform,
"BECImageShow": BECImageShow,
"BECMotorMap": BECMotorMap,
"BECMultiWaveform": BECMultiWaveform,
}
widget_method_map = {
"BECWaveform": "plot",
"BECImageShow": "image",
"BECMotorMap": "motor_map",
"BECMultiWaveform": "multi_waveform",
}
widget_method_map = {"BECWaveform": "plot", "BECImageShow": "image", "BECMotorMap": "motor_map"}
clean_signal = pyqtSignal()
@@ -445,10 +462,27 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
return motor_map
def multi_waveform(
self,
monitor: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
):
multi_waveform = self.subplot_factory(
widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return multi_waveform
multi_waveform.set_monitor(monitor)
return multi_waveform
def subplot_factory(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
row: int = None,
col: int = None,
@@ -500,7 +534,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
def add_widget(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
widget_id: str = None,
row: int = None,

View File

@@ -6,19 +6,22 @@ from typing import Any, Literal, Optional
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field, ValidationError
from pydantic import Field, ValidationError
from qtpy.QtCore import QThread, Slot
from qtpy.QtWidgets import QWidget
# from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig
from bec_widgets.widgets.figure.plots.image.image_processor import (
from bec_widgets.widgets.containers.figure.plots.image.image_item import (
BECImageItem,
ImageItemConfig,
)
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageProcessor,
ImageStats,
ProcessorWorker,
)
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger

View File

@@ -8,10 +8,13 @@ from bec_lib.logger import bec_logger
from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.widgets.figure.plots.image.image_processor import ImageStats, ProcessingConfig
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageStats,
ProcessingConfig,
)
if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
logger = bec_logger.logger

View File

@@ -15,8 +15,8 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import Signal, SignalData
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Signal, SignalData
logger = bec_logger.logger

View File

@@ -0,0 +1,353 @@
from collections import deque
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class BECMultiWaveformConfig(SubplotConfig):
color_palette: Optional[str] = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: Optional[int] = Field(
200, description="The maximum number of curves to display on the plot."
)
flush_buffer: Optional[bool] = Field(
False, description="Flush the buffer of the plot widget when the curve limit is reached."
)
monitor: Optional[str] = Field(
None, description="The monitor to set for the plot widget."
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
highlight_last_curve: Optional[bool] = Field(
True, description="Highlight the last curve on the plot."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
class BECMultiWaveform(BECPlotBase):
monitor_signal_updated = Signal()
highlighted_curve_index_changed = Signal(int)
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"curves",
"set_monitor",
"set_opacity",
"set_curve_limit",
"set_curve_highlight",
"set_colormap",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_colormap",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"get_all_data",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[BECMultiWaveformConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self.old_scan_id = None
self.scan_id = None
self.monitor = None
self.connected = False
self.current_highlight_index = 0
self._curves = deque()
self.visible_curves = []
self.number_of_visible_curves = 0
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
@property
def curves(self) -> deque:
"""
Get the curves of the plot widget as a deque.
Returns:
deque: Deque of curves.
"""
return self._curves
@curves.setter
def curves(self, value: deque):
self._curves = value
@property
def highlight_last_curve(self) -> bool:
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
return self.config.highlight_last_curve
@highlight_last_curve.setter
def highlight_last_curve(self, value: bool):
self.config.highlight_last_curve = value
def set_monitor(self, monitor: str):
"""
Set the monitor for the plot widget.
Args:
monitor (str): The monitor to set.
"""
self.config.monitor = monitor
self._connect_monitor()
def _connect_monitor(self):
"""
Connect the monitor to the plot widget.
"""
try:
previous_monitor = self.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and self.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
if self.config.monitor and self.connected is False:
self.bec_dispatcher.connect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
)
self.connected = True
self.monitor = self.config.monitor
@Slot(dict, dict)
def on_monitor_1d_update(self, msg: dict, metadata: dict):
"""
Update the plot widget with the monitor data.
Args:
msg(dict): The message data.
metadata(dict): The metadata of the message.
"""
data = msg.get("data", None)
current_scan_id = metadata.get("scan_id", None)
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self.clear_curves()
self.curves.clear()
if self.crosshair:
self.crosshair.clear_markers()
# Always create a new curve and add it
curve = pg.PlotDataItem()
curve.setData(data)
self.plot_item.addItem(curve)
self.curves.append(curve)
# Max Trace and scale colors
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
self.monitor_signal_updated.emit()
@Slot(int)
def set_curve_highlight(self, index: int):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
num_visible_curves = len(self.plot_item.visible_curves)
self.number_of_visible_curves = num_visible_curves
if num_visible_curves == 0:
return # No curves to highlight
if index >= num_visible_curves:
index = num_visible_curves - 1
elif index < 0:
index = num_visible_curves + index
self.current_highlight_index = index
num_colors = num_visible_curves
colors = Colors.evenly_spaced_colors(
colormap=self.config.color_palette, num=num_colors, format="HEX"
)
for i, curve in enumerate(self.plot_item.visible_curves):
curve.setPen()
if i == self.current_highlight_index:
curve.setPen(pg.mkPen(color=colors[i], width=5))
curve.setAlpha(alpha=1, auto=False)
curve.setZValue(1)
else:
curve.setPen(pg.mkPen(color=colors[i], width=1))
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
curve.setZValue(0)
self.highlighted_curve_index_changed.emit(self.current_highlight_index)
@Slot(int)
def set_opacity(self, opacity: int):
"""
Set the opacity of the curve on the plot.
Args:
opacity(int): The opacity of the curve. 0-100.
"""
self.config.opacity = max(0, min(100, opacity))
self.set_curve_highlight(self.current_highlight_index)
@Slot(int, bool)
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
self.config.curve_limit = max_trace
self.config.flush_buffer = flush_buffer
if self.config.curve_limit is None:
self.scale_colors()
return
if self.config.flush_buffer:
# Remove excess curves from the plot and the deque
while len(self.curves) > self.config.curve_limit:
curve = self.curves.popleft()
self.plot_item.removeItem(curve)
else:
# Hide or show curves based on the new max_trace
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
for i, curve in enumerate(self.curves):
if i < len(self.curves) - num_curves_to_show:
curve.hide()
else:
curve.show()
self.scale_colors()
def scale_colors(self):
"""
Scale the colors of the curves based on the current colormap.
"""
if self.config.highlight_last_curve:
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
else:
self.set_curve_highlight(self.current_highlight_index)
def set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
self.config.color_palette = colormap
self.set_curve_highlight(self.current_highlight_index)
def hook_crosshair(self) -> None:
super().hook_crosshair()
if self.crosshair:
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
if self.curves:
self.crosshair.update_highlighted_curve(self.current_highlight_index)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
data = {}
try:
import pandas as pd
except ImportError:
pd = None
if output == "pandas":
logger.warning(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
)
output = "dict"
curve_keys = []
curves_list = list(self.curves)
for i, curve in enumerate(curves_list):
x_data, y_data = curve.getData()
if x_data is not None or y_data is not None:
key = f"curve_{i}"
curve_keys.append(key)
if output == "dict":
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
elif output == "pandas" and pd is not None:
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
if output == "pandas" and pd is not None:
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
return combined_data
return data
def clear_curves(self):
"""
Remove all curves from the plot, excluding crosshair items.
"""
items_to_remove = []
for item in self.plot_item.items:
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
items_to_remove.append(item)
for item in items_to_remove:
self.plot_item.removeItem(item)
def export_to_matplotlib(self):
"""
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
"""
MatplotlibExporter(self.plot_item).export()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.figure import BECFigure
app = QApplication(sys.argv)
widget = BECFigure()
widget.show()
sys.exit(app.exec_())

View File

@@ -147,6 +147,9 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
for axis in ["left", "bottom", "right", "top"]:
self.plot_item.getAxis(axis).setPen(text_pen)
self.plot_item.getAxis(axis).setTextPen(text_pen)
if self.plot_item.legend is not None:
for sample, label in self.plot_item.legend.items:
label.setText(label.text, color=palette.text().color())
def set(self, **kwargs) -> None:
"""
@@ -394,8 +397,8 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair.positionChanged.connect(self.crosshair_position_changed)
self.crosshair.positionClicked.connect(self.crosshair_position_clicked)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
@@ -404,8 +407,8 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def unhook_crosshair(self) -> None:
"""Unhook the crosshair from all plots."""
if self.crosshair is not None:
self.crosshair.positionChanged.disconnect(self.crosshair_position_changed)
self.crosshair.positionClicked.disconnect(self.crosshair_position_clicked)
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)

View File

@@ -19,8 +19,8 @@ from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import (
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import (
BECCurve,
CurveConfig,
Signal,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
@@ -11,7 +11,7 @@ from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.waveform import BECWaveform1D
from bec_widgets.widgets.containers.figure.plots.waveform import BECWaveform1D
logger = bec_logger.logger

View File

@@ -0,0 +1,9 @@
from qtpy.QtWidgets import QMainWindow
from bec_widgets.utils import BECConnector
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)

View File

@@ -4,7 +4,7 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_abort.button_abort import AbortButton
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
DOM_XML = """
<ui language='c++'>

View File

@@ -9,6 +9,7 @@ from bec_widgets.utils.bec_widget import BECWidget
class AbortButton(BECWidget, QWidget):
"""A button that abort the scan."""
PLUGIN = True
ICON_NAME = "cancel"
def __init__(

View File

@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.button_abort.abort_button_plugin import AbortButtonPlugin
from bec_widgets.widgets.control.buttons.button_abort.abort_button_plugin import (
AbortButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(AbortButtonPlugin())

View File

@@ -9,6 +9,7 @@ from bec_widgets.utils.bec_widget import BECWidget
class ResetButton(BECWidget, QWidget):
"""A button that resets the scan queue."""
PLUGIN = True
ICON_NAME = "restart_alt"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):

View File

@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.button_reset.reset_button_plugin import ResetButtonPlugin
from bec_widgets.widgets.control.buttons.button_reset.reset_button_plugin import (
ResetButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ResetButtonPlugin())

View File

@@ -4,7 +4,7 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_reset.button_reset import ResetButton
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
DOM_XML = """
<ui language='c++'>

View File

@@ -9,6 +9,7 @@ from bec_widgets.utils.bec_widget import BECWidget
class ResumeButton(BECWidget, QWidget):
"""A button that continue scan queue."""
PLUGIN = True
ICON_NAME = "resume"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):

View File

@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.button_resume.resume_button_plugin import ResumeButtonPlugin
from bec_widgets.widgets.control.buttons.button_resume.resume_button_plugin import (
ResumeButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ResumeButtonPlugin())

View File

@@ -4,7 +4,7 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_resume.button_resume import ResumeButton
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
DOM_XML = """
<ui language='c++'>

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.stop_button.stop_button_plugin import StopButtonPlugin
from bec_widgets.widgets.control.buttons.stop_button.stop_button_plugin import StopButtonPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(StopButtonPlugin())

View File

@@ -9,6 +9,7 @@ from bec_widgets.utils.bec_widget import BECWidget
class StopButton(BECWidget, QWidget):
"""A button that stops the current scan."""
PLUGIN = True
ICON_NAME = "dangerous"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):

View File

@@ -6,7 +6,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
DOM_XML = """
<ui language='c++'>

View File

@@ -9,7 +9,7 @@ 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"]
PLUGIN = True
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None):

View File

@@ -6,7 +6,9 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.position_indicator.position_indicator import PositionIndicator
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
DOM_XML = """
<ui language='c++'>

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.position_indicator.position_indicator_plugin import (
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator_plugin import (
PositionIndicatorPlugin,
)

View File

@@ -12,13 +12,16 @@ from bec_lib.messages import ScanQueueMessage
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout, QWidget
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
logger = bec_logger.logger
@@ -31,6 +34,7 @@ class PositionerBox(BECWidget, CompactPopupWidget):
ui_file = "positioner_box.ui"
dimensions = (234, 224)
PLUGIN = True
ICON_NAME = "switch_right"
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
@@ -97,7 +101,9 @@ class PositionerBox(BECWidget, CompactPopupWidget):
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(self, client=self.client, device_filter="Positioner")
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(self.set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
@@ -237,11 +243,18 @@ class PositionerBox(BECWidget, CompactPopupWidget):
signal = hinted_signals[0]
readback_val = signals.get(signal, {}).get("value")
if f"{self.device}_setpoint" in signals:
setpoint_val = signals.get(f"{self.device}_setpoint", {}).get("value")
for setpoint_signal in ["setpoint", "user_setpoint"]:
setpoint_val = signals.get(f"{self.device}_{setpoint_signal}", {}).get("value")
if setpoint_val is not None:
break
if f"{self.device}_motor_is_moving" in signals:
is_moving = signals.get(f"{self.device}_motor_is_moving", {}).get("value")
for moving_signal in ["motor_done_move", "motor_is_moving"]:
is_moving = signals.get(f"{self.device}_{moving_signal}", {}).get("value")
if is_moving is not None:
break
if is_moving is not None:
self.ui.spinner_widget.setVisible(True)
if is_moving:
self.ui.spinner_widget.start()
self.ui.spinner_widget.setToolTip("Device is moving")
@@ -250,6 +263,8 @@ class PositionerBox(BECWidget, CompactPopupWidget):
self.ui.spinner_widget.stop()
self.ui.spinner_widget.setToolTip("Device is idle")
self.set_global_state("success")
else:
self.ui.spinner_widget.setVisible(False)
if readback_val is not None:
self.ui.readback.setText(f"{readback_val:.{precision}f}")

View File

@@ -6,7 +6,7 @@ import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
DOM_XML = """
<ui language='c++'>

View File

@@ -1,6 +1,6 @@
from bec_lib.device import Positioner
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
class PositionerControlLine(PositionerBox):
@@ -9,6 +9,7 @@ class PositionerControlLine(PositionerBox):
ui_file = "positioner_control_line.ui"
dimensions = (60, 600) # height, width
PLUGIN = True
ICON_NAME = "switch_left"
def __init__(self, parent=None, device: Positioner = None, *args, **kwargs):

View File

@@ -6,7 +6,9 @@ import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.positioner_box.positioner_control_line import PositionerControlLine
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line import (
PositionerControlLine,
)
DOM_XML = """
<ui language='c++'>

View File

@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.positioner_box.positioner_box_plugin import PositionerBoxPlugin
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_plugin import (
PositionerBoxPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerBoxPlugin())

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.positioner_box.positioner_control_line_plugin import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line_plugin import (
PositionerControlLinePlugin,
)

View File

@@ -5,16 +5,16 @@ from __future__ import annotations
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QSize, Signal, Slot
from qtpy.QtWidgets import QGridLayout, QGroupBox, QSizePolicy, QVBoxLayout, QWidget
from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box import PositionerBox
logger = bec_logger.logger
class PositionerGroupBox(QGroupBox):
PLUGIN = True
position_update = Signal(float)
def __init__(self, parent, dev_name):

View File

@@ -6,7 +6,9 @@ import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.positioner_group.positioner_group import PositionerGroup
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
PositionerGroup,
)
DOM_XML = """
<ui language='c++'>

View File

@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.positioner_group.positioner_group_plugin import PositionerGroupPlugin
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group_plugin import (
PositionerGroupPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(PositionerGroupPlugin())

View File

@@ -0,0 +1,392 @@
from __future__ import annotations
import enum
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
logger = bec_logger.logger
class BECDeviceFilter(enum.Enum):
"""Filter for the device classes."""
DEVICE = "Device"
POSITIONER = "Positioner"
SIGNAL = "Signal"
COMPUTED_SIGNAL = "ComputedSignal"
class DeviceInputConfig(ConnectionConfig):
device_filter: list[BECDeviceFilter] = []
readout_filter: list[ReadoutPriority] = []
devices: list[str] = []
default: str | None = None
arg_name: str | None = None
apply_filter: bool = True
class DeviceInputBase(BECWidget):
"""
Mixin base class for device input widgets.
It allows to filter devices from BEC based on
device class and readout priority.
"""
_device_handler = {
BECDeviceFilter.DEVICE: Device,
BECDeviceFilter.POSITIONER: Positioner,
BECDeviceFilter.SIGNAL: BECSignal,
BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal,
}
_filter_handler = {
BECDeviceFilter.DEVICE: "filter_to_device",
BECDeviceFilter.POSITIONER: "filter_to_positioner",
BECDeviceFilter.SIGNAL: "filter_to_signal",
BECDeviceFilter.COMPUTED_SIGNAL: "filter_to_computed_signal",
ReadoutPriority.MONITORED: "readout_monitored",
ReadoutPriority.BASELINE: "readout_baseline",
ReadoutPriority.ASYNC: "readout_async",
ReadoutPriority.CONTINUOUS: "readout_continuous",
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, client=None, config=None, gui_id: str = None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
self.get_bec_shortcuts()
self._device_filter = []
self._readout_filter = []
self._devices = []
### QtSlots ###
@Slot(str)
def set_device(self, device: str):
"""
Set the device.
Args:
device (str): Default name.
"""
if self.validate_device(device) is True:
WidgetIO.set_value(widget=self, value=device)
self.config.default = device
else:
logger.warning(f"Device {device} is not in the filtered selection.")
@Slot()
def update_devices_from_filters(self):
"""Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False,
it will not apply the filters, store the filter settings and return.
"""
current_device = WidgetIO.get_value(widget=self, as_string=True)
self.config.device_filter = self.device_filter
self.config.readout_filter = self.readout_filter
if self.apply_filter is False:
return
all_dev = self.dev.enabled_devices
# Filter based on device class
devs = [dev for dev in all_dev if self._check_device_filter(dev)]
# Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs]
self.set_device(current_device)
@Slot(list)
def set_available_devices(self, devices: list[str]):
"""
Set the devices. If a device in the list is not valid, it will not be considered.
Args:
devices (list[str]): List of devices.
"""
self.apply_filter = False
self.devices = devices
### QtProperties ###
@Property(
"QStringList",
doc="List of devices. If updated, it will disable the apply filters property.",
)
def devices(self) -> list[str]:
"""
Get the list of devices for the applied filters.
Returns:
list[str]: List of devices.
"""
return self._devices
@devices.setter
def devices(self, value: list):
self._devices = value
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
@Property(str)
def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default
@default.setter
def default(self, value: str):
if self.validate_device(value) is False:
return
self.config.default = value
WidgetIO.set_value(widget=self, value=value)
@Property(bool)
def apply_filter(self):
"""Apply the filters on the devices."""
return self.config.apply_filter
@apply_filter.setter
def apply_filter(self, value: bool):
self.config.apply_filter = value
self.update_devices_from_filters()
@Property(bool)
def filter_to_device(self):
"""Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter
@filter_to_device.setter
def filter_to_device(self, value: bool):
if value is True and BECDeviceFilter.DEVICE not in self.device_filter:
self._device_filter.append(BECDeviceFilter.DEVICE)
if value is False and BECDeviceFilter.DEVICE in self.device_filter:
self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters()
@Property(bool)
def filter_to_positioner(self):
"""Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter
@filter_to_positioner.setter
def filter_to_positioner(self, value: bool):
if value is True and BECDeviceFilter.POSITIONER not in self.device_filter:
self._device_filter.append(BECDeviceFilter.POSITIONER)
if value is False and BECDeviceFilter.POSITIONER in self.device_filter:
self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters()
@Property(bool)
def filter_to_signal(self):
"""Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter
@filter_to_signal.setter
def filter_to_signal(self, value: bool):
if value is True and BECDeviceFilter.SIGNAL not in self.device_filter:
self._device_filter.append(BECDeviceFilter.SIGNAL)
if value is False and BECDeviceFilter.SIGNAL in self.device_filter:
self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters()
@Property(bool)
def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@filter_to_computed_signal.setter
def filter_to_computed_signal(self, value: bool):
if value is True and BECDeviceFilter.COMPUTED_SIGNAL not in self.device_filter:
self._device_filter.append(BECDeviceFilter.COMPUTED_SIGNAL)
if value is False and BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter:
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters()
@Property(bool)
def readout_monitored(self):
"""Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter
@readout_monitored.setter
def readout_monitored(self, value: bool):
if value is True and ReadoutPriority.MONITORED not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.MONITORED)
if value is False and ReadoutPriority.MONITORED in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters()
@Property(bool)
def readout_baseline(self):
"""Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter
@readout_baseline.setter
def readout_baseline(self, value: bool):
if value is True and ReadoutPriority.BASELINE not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.BASELINE)
if value is False and ReadoutPriority.BASELINE in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters()
@Property(bool)
def readout_async(self):
"""Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter
@readout_async.setter
def readout_async(self, value: bool):
if value is True and ReadoutPriority.ASYNC not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.ASYNC)
if value is False and ReadoutPriority.ASYNC in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters()
@Property(bool)
def readout_continuous(self):
"""Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter
@readout_continuous.setter
def readout_continuous(self, value: bool):
if value is True and ReadoutPriority.CONTINUOUS not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.CONTINUOUS)
if value is False and ReadoutPriority.CONTINUOUS in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters()
@Property(bool)
def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter
@readout_on_request.setter
def readout_on_request(self, value: bool):
if value is True and ReadoutPriority.ON_REQUEST not in self.readout_filter:
self._readout_filter.append(ReadoutPriority.ON_REQUEST)
if value is False and ReadoutPriority.ON_REQUEST in self.readout_filter:
self._readout_filter.remove(ReadoutPriority.ON_REQUEST)
self.update_devices_from_filters()
### Python Methods and Properties ###
@property
def device_filter(self) -> list[object]:
"""Get the list of filters to apply on the devices."""
return self._device_filter
@property
def readout_filter(self) -> list[str]:
"""Get the list of filters to apply on the devices"""
return self._readout_filter
def get_available_filters(self) -> list:
"""Get the available filters."""
return [entry for entry in BECDeviceFilter]
def get_readout_priority_filters(self) -> list:
"""Get the available readout priority filters."""
return [entry for entry in ReadoutPriority]
def set_device_filter(
self, filter_selection: str | BECDeviceFilter | list[str] | list[BECDeviceFilter]
):
"""
Set the device filter. If None is passed, no filters are applied and all devices included.
Args:
filter_selection (str | list[str]): Device filters. It is recommended to make an enum for the filters.
"""
filters = None
if isinstance(filter_selection, list):
filters = [self._filter_handler.get(entry) for entry in filter_selection]
if isinstance(filter_selection, str) or isinstance(filter_selection, BECDeviceFilter):
filters = [self._filter_handler.get(filter_selection)]
if filters is None or any([entry is None for entry in filters]):
logger.warning(f"Device filter {filter_selection} is not in the device filter list.")
return
for entry in filters:
setattr(self, entry, True)
def set_readout_priority_filter(
self, filter_selection: str | ReadoutPriority | list[str] | list[ReadoutPriority]
):
"""
Set the readout priority filter. If None is passed, all filters are included.
Args:
filter_selection (str | list[str]): Readout priority filters.
"""
filters = None
if isinstance(filter_selection, list):
filters = [self._filter_handler.get(entry) for entry in filter_selection]
if isinstance(filter_selection, str) or isinstance(filter_selection, ReadoutPriority):
filters = [self._filter_handler.get(filter_selection)]
if filters is None or any([entry is None for entry in filters]):
logger.warning(
f"Readout priority filter {filter_selection} is not in the readout priority list."
)
return
for entry in filters:
setattr(self, entry, True)
def _check_device_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:
"""Check if filter for device type is applied or not.
Args:
device(Device | Signal | ComputedSignal | Positioner): Device object.
"""
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
def _check_readout_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:
"""Check if filter for readout priority is applied or not.
Args:
device(Device | Signal | ComputedSignal | Positioner): Device object.
"""
return device.readout_priority in self.readout_filter
def get_device_object(self, device: str) -> object:
"""
Get the device object based on the device name.
Args:
device(str): Device name.
Returns:
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
if dev is None:
raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device."
)
return dev
def validate_device(self, device: str) -> bool:
"""
Validate the device if it is present in the filtered device selection.
Args:
device(str): Device to validate.
"""
all_devs = [dev.name for dev in self.dev.enabled_devices]
if device in self.devices and device in all_devs:
return True
return False

View File

@@ -0,0 +1,280 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import Signal
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
logger = bec_logger.logger
class DeviceSignalInputBaseConfig(ConnectionConfig):
"""Configuration class for DeviceSignalInputBase."""
signal_filter: str | list[str] | None = None
default: str | None = None
arg_name: str | None = None
device: str | None = None
signals: list[str] | None = None
class DeviceSignalInputBase(BECWidget):
"""
Mixin base class for device signal input widgets.
Mixin class for device signal input widgets. This class provides methods to get the device signal list and device
signal object based on the current text of the widget.
"""
_filter_handler = {
Kind.hinted: "include_hinted_signals",
Kind.normal: "include_normal_signals",
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self._device = None
self.get_bec_shortcuts()
self._signal_filter = []
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
)
### Qt Slots ###
@Slot(str)
def set_signal(self, signal: str):
"""
Set the signal.
Args:
signal (str): signal name.
"""
if self.validate_signal(signal) is True:
WidgetIO.set_value(widget=self, value=signal)
self.config.default = signal
else:
logger.warning(
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
)
@Slot(str)
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happpens
Args:
device(str): device name.
"""
if self.validate_device(device) is False:
self._device = None
else:
self._device = device
self.update_signals_from_filters()
@Slot(dict, dict)
@Slot()
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the device signals based on list in self.signal_filter.
In addition, store the hinted, normal and config signals in separate lists to allow
customisation within QLineEdit.
Note:
Signal and ComputedSignals have no signals. The naming convention follows the device name.
"""
self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
self._device = None
self.config.device = self._device
return
device = self.get_device_object(self._device)
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
FilterIO.set_selection(widget=self, selection=[self._device])
return
device_info = device._info["signals"]
if Kind.hinted in self.signal_filter:
hinted_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
]
self._hinted_signals = hinted_signals
if Kind.normal in self.signal_filter:
normal_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
]
self._normal_signals = normal_signals
if Kind.config in self.signal_filter:
config_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.config.value))
]
self._config_signals = config_signals
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals)
### Qt Properties ###
@Property(str)
def device(self) -> str:
"""Get the selected device."""
if self._device is None:
return ""
return self._device
@device.setter
def device(self, value: str):
"""Set the device and update the filters, only allow devices present in the devicemanager."""
self._device = value
self.config.device = value
self.update_signals_from_filters()
@Property(bool)
def include_hinted_signals(self):
"""Include hinted signals in filters."""
return Kind.hinted in self.signal_filter
@include_hinted_signals.setter
def include_hinted_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.hinted)
else:
self._signal_filter.remove(Kind.hinted)
self.update_signals_from_filters()
@Property(bool)
def include_normal_signals(self):
"""Include normal signals in filters."""
return Kind.normal in self.signal_filter
@include_normal_signals.setter
def include_normal_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.normal)
else:
self._signal_filter.remove(Kind.normal)
self.update_signals_from_filters()
@Property(bool)
def include_config_signals(self):
"""Include config signals in filters."""
return Kind.config in self.signal_filter
@include_config_signals.setter
def include_config_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.config)
else:
self._signal_filter.remove(Kind.config)
self.update_signals_from_filters()
### Properties and Methods ###
@property
def signals(self) -> list[str]:
"""
Get the list of device signals for the applied filters.
Returns:
list[str]: List of device signals.
"""
return self._signals
@signals.setter
def signals(self, value: list[str]):
self._signals = value
self.config.signals = value
FilterIO.set_selection(widget=self, selection=value)
@property
def signal_filter(self) -> list[str]:
"""Get the list of filters to apply on the device signals."""
return self._signal_filter
def get_available_filters(self) -> list[str]:
"""Get the available filters."""
return [entry for entry in self._filter_handler]
def set_filter(self, filter_selection: str | list[str]):
"""
Set the device filter. If None, all devices are included.
Args:
filter_selection (str | list[str]): Device filters from BECDeviceFilter and BECReadoutPriority.
"""
filters = None
if isinstance(filter_selection, list):
filters = [self._filter_handler.get(entry) for entry in filter_selection]
if isinstance(filter_selection, str):
filters = [self._filter_handler.get(filter_selection)]
if filters is None:
return
for entry in filters:
setattr(self, entry, True)
def get_device_object(self, device: str) -> object | None:
"""
Get the device object based on the device name.
Args:
device(str): Device name.
Returns:
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
if dev is None:
logger.warning(f"Device {device} not found in devicemanager.")
return None
return dev
def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device in self.dev:
return True
if raise_on_false is True:
raise ValueError(f"Device {device} not found in devicemanager.")
return False
def validate_signal(self, signal: str) -> bool:
"""
Validate the signal if it is present in the device signals.
Args:
signal(str): Signal to validate.
"""
if signal in self.signals:
return True
return False

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