1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

156 Commits

Author SHA1 Message Date
65dceead0c WIP adjustments to be compatible with latest bec widgets and bec 2024-11-11 16:41:28 +01:00
20125139f0 fix(dock): REBASE docks can be created as temporary, when closed the dock will not return to original dock area 2024-11-11 16:31:05 +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
semantic-release
6f0182115f 1.0.1
Automatically generated by python-semantic-release
2024-10-22 08:47:29 +00:00
7469c892c8 fix(waveform): added support for live_data and data access 2024-10-18 17:10:53 +02:00
semantic-release
cb45527f3e 1.0.0
Automatically generated by python-semantic-release
2024-10-18 09:48:29 +00:00
f9a889fc6d fix(crosshair): downsample clear markers 2024-10-18 11:32:12 +02:00
2ab12ed60a feat!: ability to disable scatter from waveform & compatible crosshair with down sampling 2024-10-18 11:32:12 +02:00
semantic-release
98c68e9ff4 0.119.0
Automatically generated by python-semantic-release
2024-10-17 15:09:10 +00:00
19f4e407e0 fix: fix syntax due to change of api for simulated devices 2024-10-17 16:07:11 +02:00
a23841b255 fix: remove wrongly scoped test 2024-10-17 16:07:11 +02:00
6982711fea fix: rename 'compact' property -> 'compact_view' 2024-10-17 16:07:11 +02:00
0015f0e2d6 fix: Alignment 1D update, make app window a main window (in .ui file) 2024-10-17 16:07:11 +02:00
af9655de0c feat: new PositionerGroup widget 2024-10-17 16:07:11 +02:00
e4121a01cb feat: add 'expand_popup' property to CompactPopupWidget
This property tells if expand should show a popup (by default), or
if the widget should expand in-place
2024-10-17 16:07:11 +02:00
a69d2870e2 refactor: redesign of scan selection and scan control boxes 2024-10-17 16:07:07 +02:00
e3d0a7bbf9 refactor: move add/remove bundle to scan group box 2024-10-17 09:29:55 +02:00
523cc43572 fix: set (Minimum, Fixed) size policy on Stop button 2024-10-17 09:29:55 +02:00
261578796f feat: PositionerBox with a popup view 2024-10-17 09:29:55 +02:00
0b9b1a3c89 feat: emit 'device_selected' and 'scan_axis' from scan control widget 2024-10-14 16:45:26 +02:00
9801d2769e feat: new 'device_selected' signals to ScanControl, ScanGroupBox, DeviceLineEdit 2024-10-14 16:45:26 +02:00
semantic-release
dfccf97a99 0.118.0
Automatically generated by python-semantic-release
2024-10-13 14:18:42 +00:00
9ef1d1c9ac feat(image): image widget can take data from monitor_1d endpoint 2024-10-13 16:13:53 +02:00
b23695167a docs(sphinx-build): adjusted pyside verion 2024-10-11 17:36:24 +02:00
semantic-release
92cc808d65 0.117.1
Automatically generated by python-semantic-release
2024-10-11 15:27:05 +00:00
3a22392780 fix(FPS): qtimer cleanup leaking 2024-10-11 17:17:50 +02:00
f5f1f6c304 feature(vscode): added support for vscode instructions 2024-10-11 15:36:56 +02:00
923867947f feature(vscode): support for controlling vscode from widgets 2024-10-11 15:36:56 +02:00
semantic-release
91260bb579 0.117.0
Automatically generated by python-semantic-release
2024-10-11 10:29:41 +00:00
8dc892df0a tests(plot_base): tests extended 2024-10-11 12:17:17 +02:00
8c5ef26843 feat(utils): FPS counter utility based on the viewBox updates, integrated to waveform and image widget 2024-10-11 09:52:48 +02:00
semantic-release
b681b13a33 0.116.0
Automatically generated by python-semantic-release
2024-10-11 07:17:54 +00:00
499b6b9a12 feat: UI changes to have top toolbar with compact popup widgets (fix issue #360) 2024-10-11 09:08:37 +02:00
94ce92f5b0 feat: adapt BECQueue and BECStatusBox widgets to use CompactPopupWidget 2024-10-11 09:08:37 +02:00
49268e3829 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
2024-10-11 09:08:37 +02:00
908dbc1760 build: fix PySide6 to 6.7.2 2024-10-10 22:42:16 +02:00
semantic-release
d7e6506a27 0.115.0
Automatically generated by python-semantic-release
2024-10-08 09:48:59 +00:00
c5e9ed6e42 fix: make Alignment1D a MainWindow as it is an application 2024-10-08 11:39:43 +02:00
b207e45a67 fix: adjust bec_qthemes dependency 2024-10-08 11:39:43 +02:00
8bf4842788 feat: add bec-app script to launch applications 2024-10-08 11:39:43 +02:00
semantic-release
49b9bfc9d3 0.114.0
Automatically generated by python-semantic-release
2024-10-02 20:32:01 +00:00
04cfb1edf1 fix: prevent exception when empty string updates are coming from widget 2024-10-02 16:17:24 +02:00
efa276358b 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
2024-10-02 16:17:24 +02:00
f084e2514b 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.
2024-10-02 16:17:24 +02:00
semantic-release
7cd0b3630e 0.113.0
Automatically generated by python-semantic-release
2024-10-02 11:47:57 +00:00
dc0c825fd5 test: add tests for scan_status_callback 2024-10-01 22:16:16 +02:00
1dcfeb6cfc feat : Add bec_signal_proxy to handle signals with option to unblock them manually. 2024-10-01 22:16:16 +02:00
f554f3c167 refactor: various minor improvements for the alignment gui 2024-10-01 22:16:16 +02:00
0f9953e8fd fix: add is_log checks and functionality to plot_indicator_items 2024-10-01 22:16:16 +02:00
63c24f97a3 feat: add first draft for alignment_1d GUI 2024-10-01 22:16:16 +02:00
efe90eb163 refactor: allow hiding of arg/kwarg boxes 2024-10-01 22:16:16 +02:00
281cb27d8b feat: add move to position button to lmfit dialog 2024-10-01 22:16:16 +02:00
5c740371d8 refactor: add proxy to waveform to limit the dap_request frequency 2024-10-01 22:16:16 +02:00
28ee3856be refactor: update dap_model also if x and y axis are selected 2024-10-01 22:16:16 +02:00
7cc0726398 refactor: linear_region_selector accepts log_x data 2024-10-01 22:16:16 +02:00
e039304fd3 refactor: use accent colors for bec_status_box icons; closes #338 2024-09-26 12:07:33 +02:00
semantic-release
6fa7ca8f09 0.112.1
Automatically generated by python-semantic-release
2024-09-19 09:05:41 +00:00
b2f7d3c5f3 fix: test e2e dap wait_for_fit 2024-09-19 09:30:26 +02:00
e3b5e338bf docs(dap_combo_box): updated screenshot 2024-09-18 14:15:06 +02:00
c8e614b575 docs(device_box): updated screenshot 2024-09-18 14:00:10 +02:00
semantic-release
8e44ca1ad0 0.112.0
Automatically generated by python-semantic-release
2024-09-17 08:13:25 +00:00
286ad7196b feat: console: various improvements, auto-adapt rows to widget size, Qt Designer plugin 2024-09-17 10:08:49 +02:00
semantic-release
adef25f4e2 0.111.0
Automatically generated by python-semantic-release
2024-09-17 04:41:08 +00:00
60f7d54e2b docs(position_indicator): updated position indicator documentation and added designer properties 2024-09-16 16:56:58 +02:00
dd932dd8f3 fix(position_indicator): fixed user access 2024-09-16 16:56:58 +02:00
d3c1a1b2ed fix(generate_cli): fixed type annotations 2024-09-16 16:56:58 +02:00
7ea4a482e7 fix(positioner_box): visual improvements to the positioner_box and positioner_control_line 2024-09-16 13:34:39 +02:00
9045323049 fix(palette viewer): fixed background for tool tip 2024-09-14 18:57:50 +02:00
d15b22250f feat(position_indicator): improved design and added more customization options 2024-09-14 18:33:00 +02:00
semantic-release
5557bfe717 0.110.0
Automatically generated by python-semantic-release
2024-09-12 08:28:50 +00:00
a8576c164c feat(palette_viewer): added widget to display the current palette and accent colors 2024-09-12 08:58:54 +02:00
semantic-release
f5807ec5cd 0.109.1
Automatically generated by python-semantic-release
2024-09-09 15:50:30 +00:00
b0d786b991 fix: refactor textbox widget, remove inheritance, adhere to bec style; closes #324 2024-09-09 17:41:27 +02:00
semantic-release
774044d2a7 0.109.0
Automatically generated by python-semantic-release
2024-09-06 17:30:40 +00:00
84a59f70ee feat(accent colors): added helper function to get all accent colors 2024-09-06 19:26:17 +02:00
de303f0227 fix(theme): fixed theme access for themecontainer 2024-09-06 19:26:17 +02:00
semantic-release
cb2131b1de 0.108.0
Automatically generated by python-semantic-release
2024-09-06 15:18:45 +00:00
7d07cea946 docs(progressbar): added docs 2024-09-06 17:09:45 +02:00
f6d1d0bbe3 feat(progressbar): added bec progressbar 2024-09-06 17:09:45 +02:00
a52182dca9 feat(generate_cli): added support for property and qproperty setter 2024-09-06 17:09:45 +02:00
semantic-release
6731b655e7 0.107.0
Automatically generated by python-semantic-release
2024-09-06 13:34:20 +00:00
bd126dddbb refactor: change style to bec_accent_colors 2024-09-06 15:11:56 +02:00
e6976dc151 docs: extend waveform docs 2024-09-06 12:46:35 +02:00
b1aff6d791 test: add tests, including extension to end-2-end test 2024-09-06 12:46:35 +02:00
7bdca84314 feat: add roi select for dap, allow automatic clear curves on plot request 2024-09-06 12:46:35 +02:00
semantic-release
6b3ea0101e 0.106.0
Automatically generated by python-semantic-release
2024-09-05 12:52:33 +00:00
06d7741622 feat(plot_base): toggle to switch outer axes for plotting widgets 2024-09-05 14:43:20 +02:00
6b15abcc73 test: fix tests 2024-09-04 17:59:36 +02:00
998a745133 refactor: use DAPComboBox in curve_dialog selection 2024-09-04 17:18:40 +02:00
semantic-release
3c519461ec 0.105.0
Automatically generated by python-semantic-release
2024-09-04 14:40:53 +00:00
0fd5cee776 refactor: cleanup and renaming of slot/signals 2024-09-04 16:31:44 +02:00
cc691d4039 feat: add dap_combobox 2024-09-04 16:31:44 +02:00
3a5d7d0796 refactor(logger): changed prints to logger calls 2024-09-04 16:26:13 +02:00
semantic-release
814c823875 0.104.0
Automatically generated by python-semantic-release
2024-09-04 14:25:02 +00:00
90479167fb fix(scan_control): SafeSlot applied to run_scan to avoid faulty scan requests 2024-09-04 16:15:56 +02:00
730e25fd3a docs(scan_control): docs extended 2024-09-04 16:15:56 +02:00
b07e67715c test(scan_control): tests extended for getting kwargs between scan switching and getting parameters from redis 2024-09-04 16:15:56 +02:00
85dcbdaa88 refactor(scan_control): scan control layout adjusted 2024-09-04 16:15:56 +02:00
ec3bc8b519 fix(scan_control): scan parameters can be loaded from the last executed scan from redis 2024-09-04 16:15:56 +02:00
2cd9c7f585 fix(toggle): state can be determined with the widget initialisation 2024-09-04 16:15:56 +02:00
d28f9b04c4 feat(scan_control): scan control remember the previously set parameters and shares kwarg settings across scans 2024-09-04 16:15:56 +02:00
fe8dc55eb1 refactor(scan_control): basic pydantic config added 2024-09-04 16:15:56 +02:00
26920f8482 test(conftest): only run cleanup checks if test passed 2024-09-04 14:34:08 +02:00
semantic-release
8a354690c9 0.103.0
Automatically generated by python-semantic-release
2024-09-04 12:33:15 +00:00
d5eb30cd7d test(webview): fixed tests after refactoring 2024-09-04 13:21:59 +02:00
52da835803 feat(vscode): open vscode on a free port 2024-09-04 13:21:00 +02:00
9be19d4abe feat(website): added method to wait until the webpage is loaded 2024-09-04 13:21:00 +02:00
9866075100 fix(theme): fixed segfault for webengineview for auto updates 2024-09-04 13:21:00 +02:00
158c19eda7 ci: prefill variables for manual pipeline start 2024-09-04 10:02:57 +02:00
39f98ec223 test(vscode): popen call does not have to be the only one 2024-09-04 09:42:38 +02:00
semantic-release
e12a85feaa 0.102.0
Automatically generated by python-semantic-release
2024-09-04 05:55:14 +00:00
047aa26a60 docs(buttons): buttons section of docs split to appearance and queue buttons 2024-09-04 07:45:43 +02:00
9dd43aa1fd fix(queue_reset_button): queue reset has to be confirmed with msgBox 2024-09-04 07:45:43 +02:00
0d7c10e670 feat(queue): BECQueue controls extended with Resume, Stop, Abort, Reset buttons 2024-09-04 07:45:43 +02:00
df5eff3147 refactor(tests): positioner box test changed to use create_widget fixture 2024-09-03 13:39:30 +02:00
18d8561c96 docs(tests): added tests tutorial for widget 2024-09-03 13:32:41 +02:00
semantic-release
103410d4c7 0.101.0
Automatically generated by python-semantic-release
2024-09-02 11:58:55 +00:00
61ecf491e5 refactor: add docs, cleanup 2024-09-02 13:12:59 +02:00
9781b77de2 feat: add Dap dialog widget 2024-09-01 20:57:46 +02:00
semantic-release
162e0ae78b 0.100.0
Automatically generated by python-semantic-release
2024-09-01 08:14:47 +00:00
99d5e8e71c docs(becwidget): improvements to the bec widget base class docs; fixed type hint import for sphinx 2024-08-31 21:42:08 +02:00
6c1f89ad39 fix(pyqt slot): removed slot decorator to avoid problems with pyqt6 2024-08-31 14:51:12 +02:00
7fb938a850 feat(theme): added theme handler to bec widget base class; added tests 2024-08-31 14:32:38 +02:00
semantic-release
08c3d7d175 0.99.15
Automatically generated by python-semantic-release
2024-08-31 09:14:46 +00:00
af23e74f71 fix(theme): update pg axes on theme update 2024-08-31 11:11:13 +02:00
0bf1cf9b8a fix(positioner_box): fixed positioner box dialog; added test; closes #332 2024-08-31 09:45:10 +02:00
semantic-release
6dd64dd8e1 0.99.14
Automatically generated by python-semantic-release
2024-08-30 14:13:56 +00:00
99a98de8a3 fix(color_button): signal and slot added for selecting color and for emitting color after change 2024-08-30 16:03:22 +02:00
3c0e501c56 fix(color_button): inheritance changed to QWidget 2024-08-30 16:03:22 +02:00
203 changed files with 11403 additions and 1807 deletions

View File

@@ -5,8 +5,12 @@ image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
workflow:

View File

@@ -1,161 +1,209 @@
# CHANGELOG
## v0.99.13 (2024-08-30)
## v1.4.0 (2024-11-11)
### Bug Fixes
- **crosshair**: Label of coordinates of TextItem displays numbers in general format
([`11e5937`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11e5937ae0f3c1413acd4e66878a692ebe4ef7d0))
- **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
- **crosshair**: Textitem to display crosshair coordinates
([`035136d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/035136d5171ec5f4311d15a9aa5bad2bdbc1f6cb))
### Testing
- **crosshair**: Tests extended
([`64df805`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64df805a9ed92bb97e580ac3bc0a1bbd2b1cb81e))
## 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
* docs: minor updates to the widget tutorial ([`ec9c8f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ec9c8f29633364c45ebd998a5411d428c1ce488d))
- Update outdated text in docs
([`4f0693c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f0693cae34b391d75884837e1ae6353a0501868))
* docs(widget tutorial): step by step guide added ([`b32ced8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b32ced85fff628a9e1303a781630cdae3865238e))
### Fix
## v1.3.2 (2024-11-05)
* fix(dark mode button): fixed dark mode button state for external updates, including auto ([`a3110d9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a3110d98147295dcb1f9353f9aaf5461cba9232a))
### Bug Fixes
## v0.99.12 (2024-08-29)
- **plot_base**: Legend text color is changed when changing dark-light theme
([`2304c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2304c9f8497c1ab1492f3e6690bb79b0464c0df8))
### Fix
### Build System
* fix(toolbar): widget action added ([`2efd487`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2efd48736cbe04e84533f7933c552ea8274e2162))
- Pyside6 version fixed 6.7.2
([`c6e48ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6e48ec1fe5aaee6a7c7a6f930f1520cd439cdb2))
* fix(reset_button): reset button added ([`6ed1efc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ed1efc6af193908f70aa37fb73157d2ca6a62f4))
* fix(abort_button): abort button added; some minor fixes ([`a568633`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a568633c3206a8c26069d140f2d9a548bf4124b0))
## v1.3.1 (2024-10-31)
## v0.99.11 (2024-08-29)
### Bug Fixes
### Fix
- **ophyd_kind_util**: Kind enums are imported from the bec widget util class
([`940ee65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/940ee6552c1ee8d9b4e4a74c62351f2e133ab678))
* fix(resume_button): resume button added ([`8be8295`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8be8295b2b38f36da210ab36c5da6d0a00e330cc))
### Refactor
## v1.3.0 (2024-10-30)
* refactor(icons): general app icon changed; jupyter app icon changed to material icon ([`5d73fe4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d73fe455a568ad40a9fadc5ce6e249d782ad20d))
### Bug Fixes
* refactor: add option to select scan and hide arg bundle buttons ([`7dadab1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7dadab1f14aa41876ad39e8cdc7f7732248cc643))
- **colors**: Extend color map validation for matplotlib and colorcet maps (if available)
([`14dd8c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/14dd8c5b2947c92f6643b888d71975e4e8d4ee88))
## v0.99.10 (2024-08-29)
### Features
### Fix
- **colormap_button**: Colormap button with menu to select colormap filtered by the colormap type
([`b039933`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b039933405e2fbe92bd81bd0748e79e8d443a741))
* fix(stop_button): queue logic scan changed to halt instead of abort and reset ([`4a89028`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a890281f7eaef02d0ec9f4c5bf080be11fe0fe3))
### Refactor
## v1.2.0 (2024-10-25)
* refactor(stop_button): stop button changed to QWidget and adapted for toolbar ([`097946f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/097946fd688b8faf770e7cc0e689ea668206bc7a))
### Features
* refactor: added hide option for device selection button ([`cdd1752`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cdd175207e922904b2efbb2d9ecf7c556c617f2e))
- **colors**: Evenly spaced color generation + new golden ratio calculation
([`40c9fea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40c9fea35f869ef52e05948dd1989bcd99f602e0))
## v0.99.9 (2024-08-28)
### Refactoring
### Fix
- Add bec_lib version to statusbox
([`5d4b86e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d4b86e1c6e1800051afce4f991153e370767fa6))
* fix: fixed build process and excluded docs and tests from tarballs and wheels ([`719254c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/719254cf0a48e1fc4bd541edba239570778bcfea))
## v0.99.8 (2024-08-28)
## v1.1.0 (2024-10-25)
### Fix
### Features
* fix(website): fixed designer integration for website widget ([`5f37e86`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f37e862c95ac7173b6918ad39bcaef938dad698))
- Add filter i/o utility class
([`0350833`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0350833f36e0a7cadce4173f9b1d1fbfdf985375))
### Refactor
### Refactoring
* refactor(website): changed inheritance of website widget to simple qwidget; closes #325 ([`9925bbd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9925bbdb48b55eacbbce9fd6a1555a21b84221f9))
- 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
([`91959e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/91959e82de8586934af3ebb5aaa0923930effc51))
## v0.99.7 (2024-08-28)
- Allow to set selection in DeviceInput; automatic update of selection on device config update;
cleanup
([`5eb15b7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5eb15b785f12e30eb8ccbc56d4ad9e759a4cf5eb))
### Fix
- Cleanup, added device_signal for signal inputs
([`6fb2055`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6fb20552ff57978f4aeb79fd7f062f8d6b5581e7))
* fix(toolbar): material icons can accept color as kwarg ([`ffc871e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ffc871ebbd3b68abc3e151bb8f5849e6c50e775e))
### Testing
## v0.99.6 (2024-08-28)
- **scan_control**: Tests added for grid_scan to ensure scan_args signal validity
([`acb7902`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/acb79020d4be546efc001ff47b6f5cdba2ee9375))
## v1.0.2 (2024-10-22)
### Bug Fixes
- **scan_control**: Scan args signal fixed to emit list instead of hardcoded structure
([`4f5448c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f5448cf51a204e077af162c7f0aed1f1a60e57a))
## v1.0.1 (2024-10-22)
### Bug Fixes
- **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)
### Bug Fixes
- **crosshair**: Downsample clear markers
([`f9a889f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f9a889fc6d380b9e587edcb465203122ea0bffc1))
### Features
- Ability to disable scatter from waveform & compatible crosshair with down sampling
([`2ab12ed`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2ab12ed60abb995abc381d9330fdcf399796d9e5))
## v0.119.0 (2024-10-17)
### Bug Fixes
- Fix syntax due to change of api for simulated devices
([`19f4e40`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/19f4e407e00ee242973ca4c3f90e4e41a4d3e315))
- Remove wrongly scoped test
([`a23841b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a23841b2553dc7162da943715d58275c7dc39ed9))
- Rename 'compact' property -> 'compact_view'
([`6982711`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6982711fea5fb8a73845ed7c0692e3ec53ef7871))
- Alignment 1D update, make app window a main window (in .ui file)
([`0015f0e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0015f0e2d62adc02d3ef334e1f6dbb2d0288fec6))
- Set (Minimum, Fixed) size policy on Stop button
([`523cc43`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/523cc435725b10b7d59a4477a1aaa24a1f3e37a2))
### Features
- New PositionerGroup widget
([`af9655d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af9655de0c541092437accfbaa779628a2f48ccb))
- Add 'expand_popup' property to CompactPopupWidget
([`e4121a0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e4121a01cb6b8d496e630cd43bc642b994b8f310))
This property tells if expand should show a popup (by default), or if the widget should expand
in-place
- Positionerbox with a popup view
([`2615787`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/261578796f1de8ca9cab9b91659bc1484f7aa89d))
- Emit 'device_selected' and 'scan_axis' from scan control widget
([`0b9b1a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0b9b1a3c89a98505079f7d4078915b7bbfaa1e23))
- New 'device_selected' signals to ScanControl, ScanGroupBox, DeviceLineEdit
([`9801d27`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9801d2769eb0ee95c94ec0c011e1dac1407142ae))
### Refactoring
- Redesign of scan selection and scan control boxes
([`a69d287`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a69d2870e2b3539739781d741b27b8599c0f4abd))
- Move add/remove bundle to scan group box
([`e3d0a7b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e3d0a7bbf9918dc16eb7227a178c310256ce570d))
## v0.118.0 (2024-10-13)
### Documentation
* docs: various bugs fixed ([`c31e9a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c31e9a3aff3ee8e984674dee0965ee7f1b6e2b8f))
- **sphinx-build**: Adjusted pyside verion
([`b236951`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b23695167ab969f754a058ffdccca2b40f00a008))
### Fix
### Features
* fix(toolbar): use of native qt separators ([`09c6c93`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/09c6c93c397ce4a21c293f6c79106c74b2db65ca))
- **image**: Image widget can take data from monitor_1d endpoint
([`9ef1d1c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9ef1d1c9ac2178d9fa2e655942208f8abbdf5c1b))
## v0.99.5 (2024-08-28)
### Documentation
## v0.117.1 (2024-10-11)
* docs(index): index page is centered ([`02239de`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02239de0a36fcd6cbf97990b0dec1ddf7ecf6ba6))
### Bug Fixes
### Fix
* fix(dock_area): dark button added ([`e6f204b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6f204b6aa295747a68769f43af2e549149b401a))
## v0.99.4 (2024-08-28)
### Documentation
* docs(buttons): added missing buttons docs ([`4e5520a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e5520aee2115d2fc0cebb3865433478a5ec8253))
* docs(developer): tutorial for BECWidget base class ([`ac2cb51`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac2cb5197deef4d51e26ee5beb070eba3ffc210d))
### Fix
* fix(theme): apply theme to all pyqtgraph widgets on manual updates ([`c550186`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c5501860e8e07a53f4bce144d44ed39eda6290ef))
### Refactor
* refactor(buttons): changed grid and thumbnail fig in gallery ([`4591ba8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4591ba8f73e22aba7258cad93c073f1387cb74a0))
* refactor(icons): removed toolbar icons from assets ([`f335763`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f335763280adb1d83ba31f073ce206e4cb5d15ef))
* refactor(icons): moved widget icons to class attribute ICON_NAME ([`e890091`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e890091d862e42317c7a54fc414ba37c85f268b0))
## v0.99.3 (2024-08-27)
### Build
* build: updated min version of bec qthemes ([`d482434`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d48243483ef8228cc5eb85e40a6b8f5da3b45520))
### Fix
* fix(cmaps): unified all defaults to magma cmap ([`1ca9499`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1ca9499edd334c19fe1e7aac71d3940a80a1ec95))
* fix(color maps): color maps should take the background color into account; fixed min colors to 10 ([`060935f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/060935ffc5472a958c337bf60834c5291f104ece))
## v0.99.2 (2024-08-27)
### Ci
* ci: additional tests are not allowed to fail ([`bb385f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bb385f07ca18904461a541b5cadde05398c84438))
### Fix
* fix(widgets): fixed default theme for widgets
If not theme is set, the init of the BECWidget base class sets the default theme to "dark" ([`cf28730`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf28730515e3c2d5914e0205768734c578711e5c))
## v0.99.1 (2024-08-27)
### Fix
* fix(crosshair): emit all crosshair events, not just line coordinates ([`2265458`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2265458dcc57970db18c62619f5877d542d72e81))
## v0.99.0 (2024-08-25)
### Documentation
* docs(darkmodebutton): added dark mode button docs ([`406c263`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/406c263746f0e809c1a4d98356c48f40428c23d7))
### Feature
* feat(darkmodebutton): added button to toggle between dark and light mode ([`cc8c166`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cc8c166b5c1d37e0f64c83801b2347a54a6550b6))
### Fix
* fix(toggle): emit state change ([`c4f3308`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c4f3308dc0c3e4b2064760ccd7372d71b3e49f96))
### Refactor
* refactor(darkmodebutton): renamed set_dark_mode_enabled to toggle_dark_mode ([`c70724a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c70724a456900bcb06b040407a2c5d497e49ce77))
### Test
* test(dark_mode_button): added tests for dark mode button ([`df35aab`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df35aabff30c5d00b1c441132bd370446653741e))
- **FPS**: Qtimer cleanup leaking
([`3a22392`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3a2239278075de7489ad10a58c31d7d89715e221))

View File

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
<path d="M479.85-265.87q19.8 0 32.69-12.46 12.9-12.46 12.9-32.26 0-19.8-12.75-32.98-12.74-13.17-32.54-13.17-19.8 0-32.69 13.16-12.9 13.15-12.9 32.95 0 19.8 12.75 32.28 12.74 12.48 32.54 12.48Zm-36.46-166.56h79.22v-262.61h-79.22v262.61Zm36.95 366.56q-86.2 0-161.5-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.73-32.39-75.3-32.39-161.59t32.39-161.67q32.4-75.37 88.75-131.34t131.69-88.62q75.34-32.65 161.67-32.65 86.34 0 161.78 32.61 75.45 32.6 131.37 88.5 55.93 55.89 88.55 131.45 32.63 75.56 32.63 161.87 0 86.29-32.65 161.58t-88.62 131.48q-55.97 56.18-131.42 88.76-75.46 32.58-161.67 32.58Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
<path d="m759.04-283.09-63.13-62q49.31-9.43 84.44-46.02 35.13-36.59 35.13-86.14 0-55.49-39.42-95.08-39.43-39.58-94.93-39.58h-153.3v-79.79h152.74q89.28 0 151.7 62.71Q894.7-566.28 894.7-477q0 63.7-38.26 115.96-38.27 52.26-97.4 77.95ZM596.83-443.61l-65.66-66.78h110.05v66.78h-44.39ZM804.96-56 58.48-802.48 106-850l746.48 746.48L804.96-56ZM443.22-265.87H279.43q-89.28 0-151.7-62.42Q65.3-390.72 65.3-480q0-72.57 43.09-129.54 43.09-56.98 112.09-76.07l70.13 70.7h-11.18q-55.73 0-95.32 39.3-39.59 39.31-39.59 95.61t39.66 95.61q39.66 39.3 95.5 39.3h163.54v79.22ZM319.35-446.61v-66.78h77.3l66.78 66.78H319.35Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 721 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFF55">
<path d="M478.3-145.87q-138.65 0-236.39-97.74-97.74-97.74-97.74-236.25t97.74-236.68q97.74-98.16 236.39-98.16 88.4 0 155.45 35.76 67.04 35.76 115.86 98.9V-814.7h66.78v274.92H540.91V-606h165.74q-38.56-57.74-95.3-93.33-56.74-35.58-133.05-35.58-106.88 0-180.89 73.98-74.02 73.99-74.02 180.83 0 106.84 74.02 180.93 74.02 74.08 180.91 74.08 80.16 0 147.74-46.08 67.59-46.09 95.16-121.83H803q-29.56 110.65-119.67 178.89-90.1 68.24-205.03 68.24Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 559 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#75FB4C">
<path d="m419.87-289.52 289.22-289.22-57.31-56.87L419.87-403.7 304.96-518.61l-56.31 56.87 171.22 172.22Zm60.21 223.65q-85.47 0-161.01-32.39-75.53-32.4-131.97-88.84-56.44-56.44-88.84-131.89-32.39-75.46-32.39-160.93 0-86.47 32.39-162.01 32.4-75.53 88.75-131.5t131.85-88.62q75.5-32.65 161.01-32.65 86.52 0 162.12 32.61 75.61 32.6 131.53 88.5 55.93 55.89 88.55 131.45Q894.7-566.58 894.7-480q0 85.55-32.65 161.07-32.65 75.53-88.62 131.9-55.97 56.37-131.42 88.77-75.46 32.39-161.93 32.39Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 604 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#F19E39">
<path d="M27.56-112.65 480-894.7l452.44 782.05H27.56Zm456.62-125.48q13.15 0 22.61-9.64 9.47-9.65 9.47-22.8t-9.64-22.33q-9.65-9.19-22.8-9.19t-22.61 9.36q-9.47 9.36-9.47 22.51 0 13.15 9.64 22.62 9.65 9.47 22.8 9.47ZM454-348h60v-219.48h-60V-348Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 364 B

View File

@@ -1,5 +1,7 @@
# This file was automatically generated by generate_cli.py
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
@@ -14,24 +16,32 @@ class Widgets(str, enum.Enum):
"""
AbortButton = "AbortButton"
BECColorMapWidget = "BECColorMapWidget"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
BECImageWidget = "BECImageWidget"
BECMotorMapWidget = "BECMotorMapWidget"
BECProgressBar = "BECProgressBar"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
BECWaveformWidget = "BECWaveformWidget"
DapComboBox = "DapComboBox"
DarkModeButton = "DarkModeButton"
DeviceBrowser = "DeviceBrowser"
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
PositionerGroup = "PositionerGroup"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
SignalComboBox = "SignalComboBox"
SignalLineEdit = "SignalLineEdit"
StopButton = "StopButton"
TextBox = "TextBox"
VSCodeEditor = "VSCodeEditor"
@@ -56,6 +66,15 @@ class AbortButton(RPCBase):
"""
class BECColorMapWidget(RPCBase):
@property
@rpc_call
def colormap(self):
"""
Get the current colormap name.
"""
class BECCurve(RPCBase):
@rpc_call
def remove(self):
@@ -317,6 +336,13 @@ class BECDock(RPCBase):
Detach the dock from the parent dock area.
"""
@rpc_call
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
class BECDockArea(RPCBase, BECGuiClientMixin):
@property
@@ -375,6 +401,7 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
name: "str" = None,
position: "Literal['bottom', 'top', 'left', 'right', 'above', 'below']" = None,
relative_to: "BECDock | None" = None,
temporary: "bool" = False,
closable: "bool" = True,
floating: "bool" = False,
prefix: "str" = "dock",
@@ -391,6 +418,7 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
name(str): The name of the dock to be displayed and for further references. Has to be unique.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
temp(bool): Whether the dock is temporary. Upon closing the dock is not returned to the parent dock area.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
prefix(str): The prefix for the dock name if no name is provided.
@@ -546,6 +574,7 @@ class BECFigure(RPCBase):
def image(
self,
monitor: "str" = None,
monitor_type: "Literal['1d', '2d']" = "2d",
color_bar: "Literal['simple', 'full']" = "full",
color_map: "str" = "magma",
data: "np.ndarray" = None,
@@ -850,6 +879,7 @@ class BECImageShow(RPCBase):
def image(
self,
monitor: "str",
monitor_type: "Literal['1d', '2d']" = "2d",
color_map: "Optional[str]" = "magma",
color_bar: "Optional[Literal['simple', 'full']]" = "full",
downsample: "Optional[bool]" = True,
@@ -862,6 +892,7 @@ class BECImageShow(RPCBase):
Args:
monitor(str): The name of the monitor to display.
monitor_type(Literal["1d","2d"]): The type of monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
@@ -1129,6 +1160,15 @@ class BECImageShow(RPCBase):
y(bool): Show grid on the y-axis.
"""
@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):
"""
@@ -1165,6 +1205,7 @@ class BECImageWidget(RPCBase):
def image(
self,
monitor: "str",
monitor_type: "Optional[Literal['1d', '2d']]" = "2d",
color_map: "Optional[str]" = "magma",
color_bar: "Optional[Literal['simple', 'full']]" = "full",
downsample: "Optional[bool]" = True,
@@ -1324,6 +1365,15 @@ class BECImageWidget(RPCBase):
y_grid(bool): Visibility of the y-axis grid.
"""
@rpc_call
def enable_fps_monitor(self, enabled: "bool"):
"""
Enable the FPS monitor of the plot widget.
Args:
enabled(bool): If True, enable the FPS monitor.
"""
@rpc_call
def lock_aspect_ratio(self, lock: "bool"):
"""
@@ -1651,6 +1701,24 @@ class BECPlotBase(RPCBase):
y(bool): Show grid on the y-axis.
"""
@rpc_call
def set_outer_axes(self, show: "bool" = True):
"""
Set the outer axes of the plot widget.
Args:
show(bool): Show the outer axes.
"""
@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):
"""
@@ -1682,6 +1750,57 @@ class BECPlotBase(RPCBase):
"""
class BECProgressBar(RPCBase):
@rpc_call
def set_value(self, value):
"""
Set the value of the progress bar.
Args:
value (float): The value to set.
"""
@rpc_call
def set_maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
Args:
maximum (float): The maximum value.
"""
@rpc_call
def set_minimum(self, minimum: float):
"""
Set the minimum value of the progress bar.
Args:
minimum (float): The minimum value.
"""
@property
@rpc_call
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
Examples:
>>> progressbar.label_template = "$value / $maximum - $percentage %"
>>> progressbar.label_template = "$value / $percentage %"
"""
@label_template.setter
@rpc_call
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
Examples:
>>> progressbar.label_template = "$value / $maximum - $percentage %"
>>> progressbar.label_template = "$value / $percentage %"
"""
class BECQueue(RPCBase):
@property
@rpc_call
@@ -1811,6 +1930,15 @@ class BECWaveform(RPCBase):
BECCurve: The curve object.
"""
@rpc_call
def get_dap_params(self) -> "dict":
"""
Get the DAP parameters of all DAP curves.
Returns:
dict: DAP parameters of all DAP curves.
"""
@rpc_call
def set_x(self, x_name: "str", x_entry: "str | None" = None):
"""
@@ -1825,15 +1953,6 @@ class BECWaveform(RPCBase):
x_entry(str): Entry of the x signal.
"""
@rpc_call
def get_dap_params(self) -> "dict":
"""
Get the DAP parameters of all DAP curves.
Returns:
dict: DAP parameters of all DAP curves.
"""
@rpc_call
def remove_curve(self, *identifiers):
"""
@@ -1876,7 +1995,7 @@ class BECWaveform(RPCBase):
"""
@rpc_call
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict | pd.DataFrame":
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict":
"""
Extract all curve data into a dictionary or a pandas DataFrame.
@@ -2003,6 +2122,24 @@ class BECWaveform(RPCBase):
colormap(str, optional): Scale the colors of curves to colormap. If None, use the default color palette.
"""
@rpc_call
def enable_scatter(self, enable: "bool"):
"""
Enable/Disable scatter plot on all curves.
Args:
enable(bool): If True, enable scatter markers; if False, disable them.
"""
@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):
"""
@@ -2039,6 +2176,25 @@ class BECWaveform(RPCBase):
size(int): Font size of the legend.
"""
@rpc_call
def toggle_roi(self, toggled: "bool") -> "None":
"""
Toggle the linear region selector on the plot.
Args:
toggled(bool): If True, enable the linear region selector.
"""
@rpc_call
def select_roi(self, region: "tuple[float, float]"):
"""
Set the fit region of the plot widget. At the moment only a single region is supported.
To remove the roi region again, use toggle_roi_region
Args:
region(tuple[float, float]): The fit region.
"""
class BECWaveformWidget(RPCBase):
@property
@@ -2096,10 +2252,10 @@ class BECWaveformWidget(RPCBase):
self,
x_name: "str",
y_name: "str",
dap: "str",
x_entry: "str | None" = None,
y_entry: "str | None" = None,
color: "str | None" = None,
dap: "str" = "GaussianModel",
validate_bec: "bool" = True,
**kwargs,
) -> "BECCurve":
@@ -2289,6 +2445,24 @@ class BECWaveformWidget(RPCBase):
y_grid(bool): Visibility of the y-axis grid.
"""
@rpc_call
def enable_fps_monitor(self, enabled: "bool"):
"""
Enable the FPS monitor of the plot widget.
Args:
enabled(bool): If True, enable the FPS monitor.
"""
@rpc_call
def enable_scatter(self, enabled: "bool"):
"""
Enable the scatter plot of the plot widget.
Args:
enabled(bool): If True, enable the scatter plot.
"""
@rpc_call
def lock_aspect_ratio(self, lock: "bool"):
"""
@@ -2310,10 +2484,57 @@ class BECWaveformWidget(RPCBase):
Export the plot widget to Matplotlib.
"""
@rpc_call
def toggle_roi(self, checked: "bool"):
"""
Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
@rpc_call
def select_roi(self, region: "tuple"):
"""
Set the region of interest of the plot widget.
Args:
region(tuple): Region of interest.
"""
class DapComboBox(RPCBase):
@rpc_call
def select_y_axis(self, y_axis: str):
"""
Slot to update the y axis.
Args:
y_axis(str): Y axis.
"""
@rpc_call
def select_x_axis(self, x_axis: str):
"""
Slot to update the x axis.
Args:
x_axis(str): X axis.
"""
@rpc_call
def select_fit_model(self, fit_name: str | None):
"""
Slot to update the fit model.
Args:
default_device(str): Default device name.
"""
class DarkModeButton(RPCBase):
@rpc_call
def toggle_dark_mode(self) -> None:
def toggle_dark_mode(self) -> "None":
"""
Toggle the dark mode state. This will change the theme of the entire
application to dark or light mode.
@@ -2392,6 +2613,81 @@ class DeviceLineEdit(RPCBase):
"""
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.
"""
class LMFitDialog(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.
"""
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
"""
None
"""
@rpc_call
def set_range(self, min_value: float, max_value: float):
"""
Set the range of the position indicator
Args:
min_value(float): Minimum value of the range
max_value(float): Maximum value of the range
"""
@property
@rpc_call
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
@property
@rpc_call
def indicator_width(self):
"""
Property to get the width of the indicator
"""
@property
@rpc_call
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
class PositionerBox(RPCBase):
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
@@ -2414,6 +2710,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
@@ -2747,6 +3053,42 @@ class ScanControl(RPCBase):
"""
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.
"""
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.
"""
class StopButton(RPCBase):
@property
@rpc_call
@@ -2767,31 +3109,21 @@ class StopButton(RPCBase):
class TextBox(RPCBase):
@rpc_call
def set_color(self, background_color: str, font_color: str) -> None:
def set_plain_text(self, text: str) -> None:
"""
Set the background color of the widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
"""
@rpc_call
def set_text(self, text: str) -> None:
"""
Set the text of the widget.
Set the plain text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def set_font_size(self, size: int) -> None:
def set_html_text(self, text: str) -> None:
"""
Set the font size of the text in the widget.
Set the HTML text of the widget.
Args:
size (int): The font size to set.
text (str): The text to set.
"""

View File

@@ -80,7 +80,7 @@ def _get_output(process, logger) -> None:
buf.clear()
buf.append(remaining)
except Exception as e:
print(f"Error reading process output: {str(e)}")
logger.error(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
@@ -146,7 +146,7 @@ class BECGuiClientMixin:
continue
return ep.load()(gui=self)
except Exception as e:
print(f"Error loading auto update script from plugin: {str(e)}")
logger.error(f"Error loading auto update script from plugin: {str(e)}")
return None
@property
@@ -189,11 +189,12 @@ class BECGuiClientMixin:
if self._process is None or self._process.poll() is not None:
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config
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 close(self) -> None:
"""
@@ -226,7 +227,7 @@ class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None

View File

@@ -8,6 +8,7 @@ import sys
import black
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
@@ -30,6 +31,7 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
@@ -90,11 +92,27 @@ class {class_name}(RPCBase):"""
self.content += """...
"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
self.content += """
is_property_setter = False
obj = getattr(cls, method, None)
if obj is None:
obj = getattr(cls, method.split(".setter")[0], None)
is_property_setter = True
method = method.split(".setter")[0]
if obj is None:
raise AttributeError(
f"Method {method} not found in class {cls.__name__}. Please check the USER_ACCESS list."
)
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
self.content += f"""
@{method}.setter
@rpc_call"""
else:
self.content += """
@property
@rpc_call"""
sig = str(inspect.signature(obj.fget))
doc = inspect.getdoc(obj.fget)
else:

View File

@@ -53,9 +53,11 @@ class BECWidgetsCLIServer:
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
@@ -63,9 +65,10 @@ class BECWidgetsCLIServer:
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
logger.error(f"Error while executing RPC instruction: {e}")
self.send_response(request_id, False, {"error": str(e)})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
@@ -83,10 +86,15 @@ class BECWidgetsCLIServer:
return obj
def run_rpc(self, obj, method, args, kwargs):
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
res = method_obj
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
sig = inspect.signature(method_obj)
if sig.parameters:
@@ -113,6 +121,7 @@ class BECWidgetsCLIServer:
return obj
def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}")
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
@@ -120,6 +129,7 @@ class BECWidgetsCLIServer:
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
@@ -137,7 +147,8 @@ class SimpleFileLikeFromLogOutputFunc:
def flush(self):
lines, _, remaining = "".join(self._buffer).rpartition("\n")
self._log_func(lines)
if lines:
self._log_func(lines)
self._buffer = [remaining]
def close(self):
@@ -155,12 +166,12 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# if no config is provided, use the default config
service_config = ServiceConfig()
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
# bec_logger.configure(
# service_config.redis,
# QtRedisConnector,
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
return server
@@ -175,6 +186,12 @@ def main():
import bec_widgets
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
if __name__ != "__main__":
# if not running as main, set the log level to critical
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
@@ -197,7 +214,7 @@ def main():
)
gui_class = BECFigure
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
@@ -233,5 +250,5 @@ def main():
if __name__ == "__main__": # pragma: no cover
sys.argv = ["bec_widgets.cli.server", "--id", "test", "--gui_class", "BECDockArea"]
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
main()

View File

@@ -164,7 +164,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("eiger")
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)

View File

@@ -16,7 +16,6 @@ class TicTacToe(QWidget): # pragma: no cover
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
print("TicTac HERE !!!!!!")
def minimumSizeHint(self):
return QSize(200, 200)

View File

@@ -0,0 +1,268 @@
import time
from types import SimpleNamespace
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import get_accent_colors
class LedLabel(QLabel):
success_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:1, y2:1, stop:0 %s, stop:1 %s);"
emergency_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:0.92, y2:0.988636, stop:0 %s, stop:1 %s);"
warning_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.232, y1:0.272, x2:0.98, y2:0.959773, stop:0 %s, stop:1 %s);"
default_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.04, y1:0.0565909, x2:0.799, y2:0.795, stop:0 %s, stop:1 %s);"
def __init__(self, parent=None):
super().__init__(parent)
self.palette = get_accent_colors()
if self.palette is None:
# no theme!
self.palette = SimpleNamespace(
default=QColor("blue"),
success=QColor("green"),
warning=QColor("orange"),
emergency=QColor("red"),
)
self.setState("default")
self.setFixedSize(20, 20)
def setState(self, state: str):
match state:
case "success":
r, g, b, a = self.palette.success.getRgb()
self.setStyleSheet(
LedLabel.success_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "default":
r, g, b, a = self.palette.default.getRgb()
self.setStyleSheet(
LedLabel.default_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "warning":
r, g, b, a = self.palette.warning.getRgb()
self.setStyleSheet(
LedLabel.warning_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "emergency":
r, g, b, a = self.palette.emergency.getRgb()
self.setStyleSheet(
LedLabel.emergency_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case unknown_state:
raise ValueError(
f"Unknown state {repr(unknown_state)}, must be one of default, success, warning or emergency"
)
class PopupDialog(QDialog):
def __init__(self, content_widget):
self.parent = content_widget.parent()
self.content_widget = content_widget
super().__init__(self.parent)
self.setAttribute(Qt.WA_DeleteOnClose)
self.content_widget.setParent(self)
QVBoxLayout(self)
self.layout().addWidget(self.content_widget)
self.content_widget.setVisible(True)
def closeEvent(self, event):
self.content_widget.setVisible(False)
self.content_widget.setParent(self.parent)
self.done(True)
class CompactPopupWidget(QWidget):
"""Container widget, that can display its content or have a compact form,
in this case clicking on a small button pops the contained widget up.
In the compact form, a LED-like indicator shows a status indicator.
"""
expand = Signal(bool)
def __init__(self, parent=None, layout=QVBoxLayout):
super().__init__(parent)
self._popup_window = None
self._expand_popup = True
QVBoxLayout(self)
self.compact_view_widget = QWidget(self)
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
QHBoxLayout(self.compact_view_widget)
self.compact_view_widget.layout().setSpacing(0)
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
self.compact_view_widget.layout().addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
)
self.compact_label = QLabel(self.compact_view_widget)
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QPushButton(self.compact_view_widget)
self.compact_show_popup.setFlat(True)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)
self.compact_view_widget.layout().addWidget(self.compact_label)
self.compact_view_widget.layout().addWidget(self.compact_status)
self.compact_view_widget.layout().addWidget(self.compact_show_popup)
self.compact_view_widget.setVisible(False)
self.layout().addWidget(self.compact_view_widget)
self.container = QWidget(self)
self.layout().addWidget(self.container)
self.container.setVisible(True)
layout(self.container)
self.layout = self.container.layout()
self.compact_show_popup.clicked.connect(self.show_popup)
def set_global_state(self, state: str):
"""Set the LED-indicator state
The LED indicator represents the 'global' state. State can be one of the
following: "default", "success", "warning", "emergency"
"""
self.compact_status.setState(state)
def show_popup(self):
"""Display the contained widgets in a popup dialog"""
if self._expand_popup:
# show popup
self._popup_window = PopupDialog(self.container)
self._popup_window.show()
self._popup_window.finished.connect(lambda: self.expand.emit(False))
self.expand.emit(True)
else:
if self.compact_view:
# expand in place
self.compact_view = False
self.compact_view_widget.setVisible(True)
self.compact_label.setVisible(False)
self.compact_status.setVisible(False)
self.compact_show_popup.setIcon(
material_icon(
icon_name="collapse_content", size=(10, 10), convert_to_pixmap=False
)
)
self.expand.emit(True)
else:
# back to compact form
self.compact_label.setVisible(True)
self.compact_status.setVisible(True)
self.compact_show_popup.setIcon(
material_icon(
icon_name="expand_content", size=(10, 10), convert_to_pixmap=False
)
)
self.compact_view = True
self.expand.emit(False)
def setSizePolicy(self, size_policy1, size_policy2=None):
# setting size policy on the compact popup widget will set
# the policy for the container, and for itself
if size_policy2 is None:
# assuming first form: setSizePolicy(QSizePolicy)
self.container.setSizePolicy(size_policy1)
QWidget.setSizePolicy(self, size_policy1)
else:
self.container.setSizePolicy(size_policy1, size_policy2)
QWidget.setSizePolicy(self, size_policy1, size_policy2)
def addWidget(self, widget):
"""Add a widget to the popup container
The popup container corresponds to the "full view" (not compact)
The widget is reparented to the container, and added to the container layout
"""
widget.setParent(self.container)
self.container.layout().addWidget(widget)
@Property(bool)
def compact_view(self):
return self.compact_label.isVisible()
@compact_view.setter
def compact_view(self, set_compact: bool):
"""Sets the compact form
If set_compact is True, the compact view is displayed ; otherwise,
the full view is displayed. This is handled by toggling visibility of
the container widget or the compact view widget.
"""
if set_compact:
self.compact_view_widget.setVisible(True)
self.container.setVisible(False)
QWidget.setSizePolicy(self, QSizePolicy.Fixed, QSizePolicy.Fixed)
else:
self.compact_view_widget.setVisible(False)
self.container.setVisible(True)
QWidget.setSizePolicy(self, self.container.sizePolicy())
if self.parentWidget():
self.parentWidget().adjustSize()
else:
self.adjustSize()
@Property(str)
def label(self):
return self.compact_label.text()
@label.setter
def label(self, compact_label_text: str):
"""Set the label text associated to the compact view"""
self.compact_label.setText(compact_label_text)
@Property(str)
def tooltip(self):
return self.compact_label.toolTip()
@tooltip.setter
def tooltip(self, tooltip: str):
"""Set the tooltip text associated to the compact view"""
self.compact_label.setToolTip(tooltip)
self.compact_status.setToolTip(tooltip)
@Property(bool)
def expand_popup(self):
return self._expand_popup
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()

View File

@@ -0,0 +1,183 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
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
class PaletteViewer(BECWidget, QWidget):
"""
This class is a widget that displays current palette colors.
"""
ICON_NAME = "palette"
def __init__(self, *args, parent=None, **kwargs):
super().__init__(*args, theme_update=True, **kwargs)
QWidget.__init__(self, parent=parent)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)
layout.addWidget(dark_mode_button)
# Create a scroll area to hold the color boxes
scroll_area = QScrollArea(self)
scroll_area.setWidgetResizable(True)
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create a frame to hold the color boxes
self.frame = QFrame(self)
self.frame_layout = QGridLayout(self.frame)
self.frame_layout.setSpacing(0)
self.frame_layout.setContentsMargins(0, 0, 0, 0)
scroll_area.setWidget(self.frame)
layout.addWidget(scroll_area)
self.setLayout(layout)
self.update_palette()
def apply_theme(self, theme) -> None:
"""
Apply the theme to the widget.
Args:
theme (str): The theme to apply.
"""
self.update_palette()
def clear_palette(self) -> None:
"""
Clear the palette colors from the frame.
Recursively removes all widgets and layouts in the frame layout.
"""
# Iterate over all items in the layout in reverse to safely remove them
for i in reversed(range(self.frame_layout.count())):
item = self.frame_layout.itemAt(i)
# If the item is a layout, clear its contents
if isinstance(item, QHBoxLayout):
# Recursively remove all widgets from the layout
for j in reversed(range(item.count())):
widget = item.itemAt(j).widget()
if widget:
item.removeWidget(widget)
widget.deleteLater()
self.frame_layout.removeItem(item)
# If the item is a widget, remove and delete it
elif item.widget():
widget = item.widget()
self.frame_layout.removeWidget(widget)
widget.deleteLater()
def update_palette(self) -> None:
"""
Update the palette colors in the frame.
"""
self.clear_palette()
palette_label = QLabel("Palette Colors (e.g. palette.windowText().color())")
palette_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(palette_label, 0, 0)
palette = get_theme_palette()
# Add the palette colors (roles) to the frame
palette_roles = [
palette.windowText,
palette.toolTipText,
palette.placeholderText,
palette.text,
palette.buttonText,
palette.highlight,
palette.link,
palette.light,
palette.midlight,
palette.mid,
palette.shadow,
palette.button,
palette.brightText,
palette.toolTipBase,
palette.alternateBase,
palette.dark,
palette.base,
palette.window,
palette.highlightedText,
palette.linkVisited,
]
offset = 1
for i, pal in enumerate(palette_roles):
i += offset
color = pal().color()
label_layout = QHBoxLayout()
color_label = QLabel(f"{pal().color().name()} ({pal.__name__})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i, 0)
# add a horizontal spacer
spacer = QLabel()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self.frame_layout.addWidget(spacer, i + 1, 0)
accent_colors_label = QLabel("Accent Colors (e.g. accent_colors.default)")
accent_colors_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(accent_colors_label, i + 2, 0)
accent_colors = get_accent_colors()
items = [
(accent_colors.default, "default"),
(accent_colors.success, "success"),
(accent_colors.warning, "warning"),
(accent_colors.emergency, "emergency"),
(accent_colors.highlight, "highlight"),
]
offset = len(palette_roles) + 2
for i, (color, name) in enumerate(items):
i += offset
label_layout = QHBoxLayout()
color_label = QLabel(f"{color.name()} ({name})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i + 2, 0)
def background_label_with_clipboard(self, color) -> QLabel:
"""
Create a label with a background color that copies the color to the clipboard when clicked.
Args:
color (QColor): The color to display in the background.
Returns:
QLabel: The label with the background color.
"""
button = QLabel()
button.setStyleSheet(f"QLabel {{ background-color: {color.name()}; }}")
button.setToolTip("Click to copy color to clipboard")
button.setCursor(Qt.PointingHandCursor)
button.mousePressEvent = lambda event: QApplication.clipboard().setText(color.name())
return button
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
viewer = PaletteViewer()
viewer.show()
sys.exit(app.exec_())

View File

@@ -1,13 +1,24 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Literal
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMenu, QToolBar, QToolButton, QWidget
from qtpy.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QMenu,
QSizePolicy,
QToolBar,
QToolButton,
QWidget,
)
import bec_widgets
@@ -152,19 +163,52 @@ class WidgetAction(ToolBarAction):
"""
def __init__(self, label: str | None = None, widget: QWidget = None):
super().__init__()
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
super().__init__(parent)
self.label = label
self.widget = widget
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
layout = QHBoxLayout(widget)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
if self.label is not None:
label = QLabel(f"{self.label}")
layout.addWidget(label)
label_widget = QLabel(f"{self.label}")
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox):
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
else:
self.widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
layout.addWidget(self.widget)
toolbar.addWidget(widget)
toolbar.addWidget(container)
@staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int:
"""
Calculate the minimum width required to display the longest item in the combo box.
Args:
combo_box (QComboBox): The combo box to calculate the width for.
Returns:
int: The calculated minimum width in pixels.
"""
font_metrics = combo_box.fontMetrics()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
return max_width + 60
class ExpandableMenuAction(ToolBarAction):

View File

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

@@ -6,7 +6,7 @@ import time
import uuid
from typing import Optional
import yaml
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
@@ -17,6 +17,7 @@ from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -81,9 +82,9 @@ class BECConnector:
# the function depends on BECClient, and BECDispatcher
@pyqtSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
print("Disconnecting", repr(dispatcher))
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
print("Shutting down BEC Client", repr(client))
logger.info("Shutting down BEC Client", repr(client))
client.shutdown()
BECConnector.EXIT_HANDLERS[self.client] = terminate
@@ -93,7 +94,7 @@ class BECConnector:
self.config = config
self.config.widget_class = self.__class__.__name__
else:
print(
logger.debug(
f"No initial config found for {self.__class__.__name__}.\n"
f"Initializing with default config."
)

View File

@@ -6,11 +6,14 @@ from typing import TYPE_CHECKING, Union
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
logger = bec_logger.logger
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
@@ -65,11 +68,6 @@ class QtRedisConnector(RedisConnector):
cb(msg.content, msg.metadata)
class BECClientWithoutLoggerInit(BECClient):
def _initialize_logger(self):
return
class BECDispatcher:
"""Utility class to keep track of slots connected to a particular redis connector"""
@@ -94,24 +92,22 @@ class BECDispatcher:
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClientWithoutLoggerInit(
config=config, connector_cls=QtRedisConnector
) # , forced=True)
else:
self.client = BECClientWithoutLoggerInit(
connector_cls=QtRedisConnector
) # , forced=True)
self.client = BECClient(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
else:
if self.client.started:
# have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector")
self.client.shutdown()
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
try:
self.client.start()
except redis.exceptions.ConnectionError:
print("Could not connect to Redis, skipping start of BECClient.")
logger.warning("Could not connect to Redis, skipping start of BECClient.")
logger.success("Initialized BECDispatcher")
self._initialized = True
@classmethod

View File

@@ -0,0 +1,54 @@
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
will allow you to decide by yourself when to unblock and execute the callback again."""
from pyqtgraph import SignalProxy
from qtpy.QtCore import Signal, Slot
class BECSignalProxy(SignalProxy):
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored
Args:
*args: Arguments to pass to the SignalProxy class
rateLimit (int): The rateLimit of the proxy
**kwargs: Keyword arguments to pass to the SignalProxy class
Example:
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)"""
is_blocked = Signal(bool)
def __init__(self, *args, rateLimit=25, **kwargs):
super().__init__(*args, rateLimit=rateLimit, **kwargs)
self._blocking = False
self.old_args = None
self.new_args = None
@property
def blocked(self):
"""Returns if the proxy is blocked"""
return self._blocking
@blocked.setter
def blocked(self, value: bool):
self._blocking = value
self.is_blocked.emit(value)
def signalReceived(self, *args):
"""Receive signal, store the args and call signalReceived from the parent class if not blocked"""
self.new_args = args
if self.blocked is True:
return
self.blocked = True
self.old_args = args
super().signalReceived(*args)
@Slot()
def unblock_proxy(self):
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
self.blocked = False
if self.new_args != self.old_args:
self.signalReceived(*self.new_args)

View File

@@ -1,8 +1,15 @@
from __future__ import annotations
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
logger = bec_logger.logger
class BECWidget(BECConnector):
"""Mixin class for all BEC widgets, to handle cleanup"""
@@ -11,15 +18,72 @@ class BECWidget(BECConnector):
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
def __init__(
self,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
theme_update: bool = False,
):
"""
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
>>> class MyWidget(BECWidget, QWidget):
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
>>> super().__init__(client=client, config=config, gui_id=gui_id)
>>> QWidget.__init__(self, parent=parent)
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration.
gui_id(str, optional): The GUI ID.
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client, config, gui_id)
super().__init__(client=client, config=config, gui_id=gui_id)
# Set the theme to auto if it is not set yet
app = QApplication.instance()
if not hasattr(app, "theme"):
set_theme("dark")
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
# Instead, we will set the theme to the system setting on startup
if darkdetect.isDark():
set_theme("dark")
else:
set_theme("light")
if theme_update:
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
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)
def _update_theme(self, theme: str):
"""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)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
Args:
theme(str, optional): The theme to be applied.
"""
def cleanup(self):
"""Cleanup the widget."""

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import itertools
import re
from typing import Literal
from typing import TYPE_CHECKING, Literal
import bec_qthemes
import numpy as np
@@ -8,17 +10,41 @@ import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication, QPushButton, QToolButton
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING:
from bec_qthemes._main import AccentColors
def get_theme_palette():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme["theme"]
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_accent_colors() -> AccentColors | None:
"""
Get the accent colors for the current theme. These colors are extensions of the color palette
and are used to highlight specific elements in the UI.
"""
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return None
return QApplication.instance().theme.accent_colors
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
@@ -27,21 +53,17 @@ def set_theme(theme: Literal["dark", "light", "auto"]):
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme)
pg.setConfigOption("background", "w" if app.theme["theme"] == "light" else "k")
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
# pylint: disable=protected-access
if theme != "auto":
return
def callback():
app.theme["theme"] = listener._theme.lower()
apply_theme(listener._theme.lower())
listener = OSThemeSwitchListener(callback)
app.installEventFilter(listener)
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def apply_theme(theme: Literal["dark", "light"]):
@@ -53,20 +75,12 @@ def apply_theme(theme: Literal["dark", "light"]):
children = itertools.chain.from_iterable(
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
)
pg.setConfigOptions(
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
)
for pg_widget in children:
pg_widget.setBackground("k" if theme == "dark" else "w")
dark_mode_buttons = [
button
for button in app.topLevelWidgets()
if hasattr(button, "dark_mode_enabled")
and hasattr(button, "mode_button")
and isinstance(button.mode_button, (QPushButton, QToolButton))
]
for button in dark_mode_buttons:
button.dark_mode_enabled = theme == "dark"
button.update_mode_button()
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
@@ -93,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.
@@ -104,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:
@@ -371,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.
@@ -379,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,21 +2,31 @@ from collections import defaultdict
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject, Qt
from qtpy.QtCore import Signal as pyqtSignal
class NonDownsamplingScatterPlotItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
pass
def setClipToView(self, state):
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):
"""
@@ -39,20 +49,69 @@ class Crosshair(QObject):
self.h_line.skip_auto_range = True
self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True)
# 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.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)
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
@@ -64,7 +123,7 @@ class Crosshair(QObject):
continue
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
marker_moved = pg.ScatterPlotItem(
marker_moved = NonDownsamplingScatterPlotItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_moved.skip_auto_range = True
@@ -73,7 +132,7 @@ class Crosshair(QObject):
# Create glowing effect markers for clicked events
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = pg.ScatterPlotItem(
marker_clicked = NonDownsamplingScatterPlotItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
@@ -142,21 +201,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.
@@ -166,17 +238,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
@@ -193,7 +263,12 @@ class Crosshair(QObject):
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
@@ -220,12 +295,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:
@@ -243,7 +316,12 @@ class Crosshair(QObject):
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))
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
@@ -263,10 +341,40 @@ class Crosshair(QObject):
for marker in self.marker_clicked_1d.values():
marker.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):
@@ -275,6 +383,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

@@ -0,0 +1,84 @@
"""
This module provides a utility class for counting and reporting frames per second (FPS) in a PyQtGraph application.
Classes:
FPSCounter: A class that monitors the paint events of a `ViewBox` to calculate and emit FPS values.
Usage:
The `FPSCounter` class can be used to monitor the rendering performance of a `ViewBox` in a PyQtGraph application.
It connects to the `ViewBox`'s paint event and calculates the FPS over a specified interval, emitting the FPS value
at regular intervals.
Example:
from qtpy import QtWidgets, QtCore
import pyqtgraph as pg
from fps_counter import FPSCounter
app = pg.mkQApp("FPS Counter Example")
win = pg.GraphicsLayoutWidget()
win.show()
vb = pg.ViewBox()
plot_item = pg.PlotItem(viewBox=vb)
win.addItem(plot_item)
fps_counter = FPSCounter(vb)
fps_counter.sigFpsUpdate.connect(lambda fps: print(f"FPS: {fps:.2f}"))
sys.exit(app.exec_())
"""
from time import perf_counter
import pyqtgraph as pg
from qtpy import QtCore
class FPSCounter(QtCore.QObject):
"""
A utility class for counting and reporting frames per second (FPS).
This class connects to a `ViewBox`'s paint event to count the number of
frames rendered and calculates the FPS over a specified interval. It emits
a signal with the FPS value at regular intervals.
Attributes:
sigFpsUpdate (QtCore.Signal): Signal emitted with the FPS value.
view_box (pg.ViewBox): The `ViewBox` instance to monitor.
"""
sigFpsUpdate = QtCore.Signal(float)
def __init__(self, view_box):
super().__init__()
self.view_box = view_box
self.view_box.sigPaint.connect(self.increment_count)
self.count = 0
self.last_update = perf_counter()
self.timer = QtCore.QTimer()
self.timer.timeout.connect(self.calculate_fps)
self.timer.start(1000)
def increment_count(self):
"""
Increment the frame count when the `ViewBox` is painted.
"""
self.count += 1
def calculate_fps(self):
"""
Calculate the frames per second (FPS) based on the number of frames
"""
now = perf_counter()
elapsed = now - self.last_update
fps = self.count / elapsed if elapsed > 0 else 0.0
self.last_update = now
self.count = 0
self.sigFpsUpdate.emit(fps)
def cleanup(self):
"""
Clean up the FPS counter by stopping the timer and disconnecting the signal.
"""
self.timer.stop()
self.timer.timeout.disconnect(self.calculate_fps)

View File

@@ -0,0 +1,84 @@
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots. """
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot
from qtpy.QtGui import QColor
class LinearRegionWrapper(QObject):
"""Wrapper class for the LinearRegionItem in pyqtgraph for 1D plots (BECWaveform)
Args:
plot_item (pg.PlotItem): The plot item to add the region selector to.
parent (QObject): The parent object.
color (QColor): The color of the region selector.
hover_color (QColor): The color of the region selector when the mouse is over it.
"""
# Signal with the region tuble (start, end)
region_changed = Signal(tuple)
def __init__(
self, plot_item: pg.PlotItem, color: QColor = None, hover_color: QColor = None, parent=None
):
super().__init__(parent)
self.is_log_x = None
self._edge_width = 2
self.plot_item = plot_item
self.linear_region_selector = pg.LinearRegionItem()
self.proxy = None
self.change_roi_color((color, hover_color))
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
# Slot for changing the color of the region selector (edge and fill)
@Slot(tuple)
def change_roi_color(self, colors: tuple[QColor | str | tuple, QColor | str | tuple]):
"""Change the color and hover color of the region selector.
Hover color means the color when the mouse is over the region.
Args:
colors (tuple): Tuple with the color and hover color
"""
color, hover_color = colors
if color is not None:
self.linear_region_selector.setBrush(pg.mkBrush(color))
if hover_color is not None:
self.linear_region_selector.setHoverBrush(pg.mkBrush(hover_color))
@Slot()
def add_region_selector(self):
"""Add the region selector to the plot item"""
self.plot_item.addItem(self.linear_region_selector)
# Use proxy to limit the update rate of the region change signal to 10Hz
self.proxy = pg.SignalProxy(
self.linear_region_selector.sigRegionChanged,
rateLimit=10,
slot=self._region_change_proxy,
)
@Slot()
def remove_region_selector(self):
"""Remove the region selector from the plot item"""
self.proxy.disconnect()
self.proxy = None
self.plot_item.removeItem(self.linear_region_selector)
def _region_change_proxy(self):
"""Emit the region change signal. If the plot is in log mode, convert the region to log."""
x_low, x_high = self.linear_region_selector.getRegion()
if self.is_log_x:
x_low = 10**x_low
x_high = 10**x_high
self.region_changed.emit((x_low, x_high))
@Slot()
def check_log(self):
"""Check if the plot is in log mode."""
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
def cleanup(self):
"""Cleanup the widget."""
self.remove_region_selector()

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

@@ -0,0 +1,247 @@
"""Module to create an arrow item for a pyqtgraph plot"""
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QPointF, Signal, Slot
from bec_widgets.utils.colors import get_accent_colors
logger = bec_logger.logger
class BECIndicatorItem(QObject):
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(parent=parent)
self.accent_colors = get_accent_colors()
self.plot_item = plot_item
self._item_on_plot = False
self._pos = None
self.is_log_x = False
self.is_log_y = False
@property
def item_on_plot(self) -> bool:
"""Returns if the item is on the plot"""
return self._item_on_plot
@item_on_plot.setter
def item_on_plot(self, value: bool) -> None:
self._item_on_plot = value
def add_to_plot(self) -> None:
"""Add the item to the plot"""
raise NotImplementedError("Method add_to_plot not implemented")
def remove_from_plot(self) -> None:
"""Remove the item from the plot"""
raise NotImplementedError("Method remove_from_plot not implemented")
def set_position(self, pos) -> None:
"""This method should implement the logic to set the position of the
item on the plot. Depending on the child class, the position can be
a tuple (x,y) or a single value, i.e. x position where y position is fixed.
"""
raise NotImplementedError("Method set_position not implemented")
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.set_position(self._pos)
class BECTickItem(BECIndicatorItem):
"""Class to create a tick item which can be added to a pyqtgraph plot.
The tick item will be added to the layout of the plot item and can be used to indicate
a position"""
position_changed = Signal(float)
position_changed_str = Signal(str)
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(plot_item=plot_item, parent=parent)
self.tick_item = pg.TickSliderItem(
parent=parent, allowAdd=False, allowRemove=False, orientation="bottom"
)
self.tick_item.skip_auto_range = True
self.tick = None
self._pos = 0.0
self._range = [0, 1]
@Slot(float)
def set_position(self, pos: float) -> None:
"""Set the x position of the tick item
Args:
pos (float): The position of the tick item.
"""
if self.is_log_x is True:
pos = pos if pos > 0 else 1e-10
pos = np.log10(pos)
self._pos = pos
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
view_range = view_box.viewRange()[0]
self.update_range(self.plot_item.vb, view_range)
self.position_changed.emit(pos)
self.position_changed_str.emit(str(pos))
@Slot()
def update_range(self, _, view_range: tuple[float, float]) -> None:
"""Update the range of the tick item
Args:
vb (pg.ViewBox): The view box.
viewRange (tuple): The view range.
"""
if self._pos < view_range[0] or self._pos > view_range[1]:
self.tick_item.setVisible(False)
else:
self.tick_item.setVisible(True)
if self.tick_item.isVisible():
origin = self.tick_item.tickSize / 2.0
length = self.tick_item.length
length_with_padding = length + self.tick_item.tickSize + 2
self._range = view_range
tick_with_padding = (self._pos - view_range[0]) / (view_range[1] - view_range[0])
tick_value = (tick_with_padding * length_with_padding - origin) / length
self.tick_item.setTickValue(self.tick, tick_value)
def add_to_plot(self):
"""Add the tick item to the view box or plot item."""
if self.plot_item is None:
return
self.plot_item.layout.addItem(self.tick_item, 2, 1)
self.tick_item.setOrientation("top")
self.tick = self.tick_item.addTick(0, movable=False, color=self.accent_colors.highlight)
self.update_tick_pos_y()
self.plot_item.vb.sigXRangeChanged.connect(self.update_range)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.plot_item.vb.geometryChanged.connect(self.update_tick_pos_y)
self.item_on_plot = True
@Slot()
def update_tick_pos_y(self):
"""Update tick position, while respecting the tick_item coordinates"""
pos = self.tick.pos()
pos = self.tick_item.mapToParent(pos)
new_pos = self.plot_item.vb.geometry().bottom()
new_pos = self.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
self.tick.setPos(new_pos)
def remove_from_plot(self):
"""Remove the tick item from the view box or plot item."""
if self.plot_item is not None and self.item_on_plot is True:
self.plot_item.vb.sigXRangeChanged.disconnect(self.update_range)
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
if self.plot_item.layout is not None:
self.plot_item.layout.removeItem(self.tick_item)
self.item_on_plot = False
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
if self.tick_item is not None:
self.tick_item.close()
self.tick_item.deleteLater()
self.tick_item = None
class BECArrowItem(BECIndicatorItem):
"""Class to create an arrow item which can be added to a pyqtgraph plot.
It can be either added directly to a view box or a plot item.
To add the arrow item to a view box or plot item, use the add_to_plot method.
Args:
view_box (pg.ViewBox | pg.PlotItem): The view box or plot item to which the arrow item should be added.
parent (QObject): The parent object.
Signals:
position_changed (tuple[float, float]): Signal emitted when the position of the arrow item has changed.
position_changed_str (tuple[str, str]): Signal emitted when the position of the arrow item has changed.
"""
# Signal to emit if the position of the arrow item has changed
position_changed = Signal(tuple)
position_changed_str = Signal(tuple)
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(plot_item=plot_item, parent=parent)
self.arrow_item = pg.ArrowItem(parent=parent)
self.arrow_item.skip_auto_range = True
self._pos = (0, 0)
self.arrow_item.setVisible(False)
@Slot(dict)
def set_style(self, style: dict) -> None:
"""Set the style of the arrow item
Args:
style (dict): The style of the arrow item. Dictionary with key,
value pairs which are accepted from the pg.ArrowItem.setStyle method.
"""
self.arrow_item.setStyle(**style)
@Slot(tuple)
def set_position(self, pos: tuple[float, float]) -> None:
"""Set the position of the arrow item
Args:
pos (tuple): The position of the arrow item as a tuple (x, y).
"""
self._pos = pos
pos_x = pos[0]
pos_y = pos[1]
if self.is_log_x is True:
pos_x = np.log10(pos_x) if pos_x > 0 else 1e-10
view_box = self.plot_item.getViewBox() # Ensure you're accessing the correct view box
view_range = view_box.viewRange()[0]
# Avoid values outside the view range in the negative direction. Otherwise, there is
# a buggy behaviour of the arrow item and it appears at the wrong position.
if pos_x < view_range[0]:
pos_x = view_range[0]
if self.is_log_y is True:
pos_y = np.log10(pos_y) if pos_y > 0 else 1e-10
self.arrow_item.setPos(pos_x, pos_y)
self.position_changed.emit(self._pos)
self.position_changed_str.emit((str(self._pos[0]), str(self._pos[1])))
def add_to_plot(self):
"""Add the arrow item to the view box or plot item."""
if not self.arrow_item:
logger.warning(f"Arrow item was already destroyed, cannot be created")
return
self.arrow_item.setStyle(
angle=-90,
pen=pg.mkPen(self.accent_colors.emergency, width=1),
brush=pg.mkBrush(self.accent_colors.highlight),
headLen=20,
)
self.arrow_item.setVisible(True)
if self.plot_item is not None:
self.plot_item.addItem(self.arrow_item)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
self.item_on_plot = True
def remove_from_plot(self):
"""Remove the arrow item from the view box or plot item."""
if self.plot_item is not None and self.item_on_plot is True:
self.plot_item.ctrl.logXCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.disconnect(self.check_log)
self.plot_item.removeItem(self.arrow_item)
self.item_on_plot = False
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
self.arrow_item = None

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,38 +1,133 @@
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: str | list[str] | None = None
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 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.
Mixin base class for device input widgets.
It allows to filter devices from BEC based on
device class and readout priority.
"""
def __init__(self, client=None, config=None, gui_id=None):
_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)
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True)
self.get_bec_shortcuts()
self._device_filter = None
self._device_filter = []
self._readout_filter = []
self._devices = []
@property
### 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.
Get the list of devices for the applied filters.
Returns:
list[str]: List of devices.
@@ -40,84 +135,258 @@ class DeviceInputBase(BECWidget):
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
def devices(self, value: list):
self._devices = value
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
def set_device_filter(self, device_filter: str | list[str]):
@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.
Set the device filter. If None is passed, no filters are applied and all devices included.
Args:
device_filter(str): Device filter, name of the device class.
filter_selection (str | list[str]): Device filters. It is recommended to make an enum for the filters.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
self._device_filter = device_filter
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_default_device(self, default_device: str):
def set_readout_priority_filter(
self, filter_selection: str | ReadoutPriority | list[str] | list[ReadoutPriority]
):
"""
Set the default device.
Set the readout priority filter. If None is passed, all filters are included.
Args:
default_device(str): Default device name.
filter_selection (str | list[str]): Readout priority filters.
"""
self.validate_device(default_device)
self.config.default = default_device
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 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.
def _check_device_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:
"""Check if filter for device type is applied or not.
Args:
filter(str|None): Class name filter to apply on the device list.
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:
devices(list[str]): List of device names.
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
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
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 get_available_filters(self):
def validate_device(self, device: str) -> bool:
"""
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.
Validate the device if it is present in the filtered device selection.
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.")
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

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar
DOM_XML = """
<ui language='c++'>
<widget class='BECProgressBar' name='bec_progress_bar'>
</widget>
</ui>
"""
class BECProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECProgressBar(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(BECProgressBar.ICON_NAME)
def includeFile(self):
return "bec_progress_bar"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECProgressBar"
def toolTip(self):
return "A custom progress bar with smooth transitions and a modern design."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,258 @@
import sys
from string import Template
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
from qtpy.QtGui import QColor, QPainter, QPainterPath
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
class BECProgressBar(BECWidget, QWidget):
"""
A custom progress bar with smooth transitions. The displayed text can be customized using a template.
"""
USER_ACCESS = [
"set_value",
"set_maximum",
"set_minimum",
"label_template",
"label_template.setter",
]
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
accent_colors = get_accent_colors()
# internal values
self._oversampling_factor = 50
self._value = 0
self._target_value = 0
self._maximum = 100 * self._oversampling_factor
# User values
self._user_value = 0
self._user_minimum = 0
self._user_maximum = 100
self._label_template = "$value / $maximum - $percentage %"
# Color settings
self._background_color = QColor(30, 30, 30)
self._progress_color = accent_colors.highlight # QColor(210, 55, 130)
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
# layout settings
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignCenter)
self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.center_label)
self.setLayout(layout)
self.update()
@Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.")
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
Examples:
>>> progressbar.label_template = "$value / $maximum - $percentage %"
>>> progressbar.label_template = "$value / $percentage %"
"""
return self._label_template
@label_template.setter
def label_template(self, template):
self._label_template = template
self.set_value(self._user_value)
self.update()
@Property(float, designable=False)
def _progressbar_value(self):
"""
The current value of the progress bar.
"""
return self._value
@_progressbar_value.setter
def _progressbar_value(self, val):
self._value = val
self.update()
def _update_template(self):
template = Template(self._label_template)
return template.safe_substitute(
value=self._user_value,
maximum=self._user_maximum,
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
)
@Slot(float)
@Slot(int)
def set_value(self, value):
"""
Set the value of the progress bar.
Args:
value (float): The value to set.
"""
if value > self._user_maximum:
value = self._user_maximum
elif value < self._user_minimum:
value = self._user_minimum
self._target_value = self.map_value(value)
self._user_value = value
self.center_label.setText(self._update_template())
self.animate_progress()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(10, 0, -10, -1)
# Draw background
painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, 10, 10) # Rounded corners
# Draw border
painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color)
painter.drawRoundedRect(rect, 10, 10)
# Determine progress color based on completion
if self._value >= self._maximum:
current_color = self._completed_color
else:
current_color = self._progress_color
# Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
)
clip_path = QPainterPath()
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
painter.setClipPath(clip_path)
# Draw progress bar
painter.setBrush(current_color)
painter.drawRect(progress_rect) # Less rounded, no additional rounding
painter.end()
def animate_progress(self):
"""
Animate the progress bar from the current value to the target value.
"""
self._value_animation.stop()
self._value_animation.setStartValue(self._value)
self._value_animation.setEndValue(self._target_value)
self._value_animation.start()
@Property(float)
def maximum(self):
"""
The maximum value of the progress bar.
"""
return self._user_maximum
@maximum.setter
def maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
"""
self.set_maximum(maximum)
@Property(float)
def minimum(self):
"""
The minimum value of the progress bar.
"""
return self._user_minimum
@minimum.setter
def minimum(self, minimum: float):
self.set_minimum(minimum)
@Property(float)
def initial_value(self):
"""
The initial value of the progress bar.
"""
return self._user_value
@initial_value.setter
def initial_value(self, value: float):
self.set_value(value)
@Slot(float)
def set_maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
Args:
maximum (float): The maximum value.
"""
self._user_maximum = maximum
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
@Slot(float)
def set_minimum(self, minimum: float):
"""
Set the minimum value of the progress bar.
Args:
minimum (float): The minimum value.
"""
self._user_minimum = minimum
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
def map_value(self, value: float):
"""
Map the user value to the range [0, 100*self._oversampling_factor] for the progress
"""
return (
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
progressBar = BECProgressBar()
progressBar.show()
progressBar.set_minimum(-100)
progressBar.set_maximum(0)
# Example of setting values
def update_progress():
value = progressBar._user_value + 2.5
if value > progressBar._user_maximum:
value = -100 # progressBar._maximum / progressBar._upsampling_factor
progressBar.set_value(value)
timer = QTimer()
timer.timeout.connect(update_progress)
timer.start(200) # Update every half second
sys.exit(app.exec())

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.bec_progressbar.bec_progress_bar_plugin import BECProgressBarPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECProgressBarPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,19 +1,38 @@
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QHBoxLayout, QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal, Slot
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.qt_utils.toolbar import ModularToolBar, SeparatorAction, WidgetAction
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.button_abort.button_abort import AbortButton
from bec_widgets.widgets.button_reset.button_reset import ResetButton
from bec_widgets.widgets.button_resume.button_resume import ResumeButton
from bec_widgets.widgets.stop_button.stop_button import StopButton
class BECQueue(BECWidget, QWidget):
class BECQueue(BECWidget, CompactPopupWidget):
"""
Widget to display the BEC queue.
"""
ICON_NAME = "edit_note"
status_colors = {
"STOPPED": "red",
"PENDING": "orange",
"IDLE": "gray",
"PAUSED": "yellow",
"DEFERRED_PAUSE": "lightyellow",
"RUNNING": "green",
"COMPLETED": "blue",
}
queue_busy = Signal(bool)
def __init__(
self,
@@ -21,19 +40,84 @@ class BECQueue(BECWidget, QWidget):
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
refresh_upon_start: bool = True,
):
super().__init__(client, config, gui_id)
QWidget.__init__(self, parent=parent)
self.table = QTableWidget(self)
self.layout = QHBoxLayout(self)
self.layout.addWidget(self.table)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
# Set up the toolbar
self.set_toolbar()
# Set up the table
self.table = QTableWidget(self)
# self.layout.addWidget(self.table)
self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status", "Cancel"])
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
self.addWidget(self.table)
self.label = "BEC Queue"
self.tooltip = "BEC Queue status"
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
self.reset_content()
if refresh_upon_start:
self.refresh_queue()
def set_toolbar(self):
"""
Set the toolbar.
"""
widget_label = QLabel("Live Queue")
widget_label.setStyleSheet("font-weight: bold;")
self.toolbar = ModularToolBar(
actions={
"widget_label": WidgetAction(widget=widget_label),
"separator_1": SeparatorAction(),
"resume": WidgetAction(widget=ResumeButton(toolbar=False)),
"stop": WidgetAction(widget=StopButton(toolbar=False)),
"reset": WidgetAction(widget=ResetButton(toolbar=False)),
},
target_widget=self,
)
self.addWidget(self.toolbar)
@Property(bool)
def hide_toolbar(self):
"""Property to hide the BEC Queue toolbar."""
return not self.toolbar.isVisible()
@hide_toolbar.setter
def hide_toolbar(self, hide: bool):
"""
Setters for the hide_toolbar property.
Args:
hide(bool): Whether to hide the toolbar.
"""
self._hide_toolbar(hide)
def _hide_toolbar(self, hide: bool):
"""
Hide the toolbar.
Args:
hide(bool): Whether to hide the toolbar.
"""
self.toolbar.setVisible(not hide)
def refresh_queue(self):
"""
Refresh the queue.
"""
msg = self.client.connector.get(MessageEndpoints.scan_queue_status())
if msg is None:
# msg is None if no scan has been run yet (fresh start)
return
self.update_queue(msg.content, msg.metadata)
@Slot(dict, dict)
def update_queue(self, content, _metadata):
@@ -57,6 +141,7 @@ class BECQueue(BECWidget, QWidget):
blocks = item.get("request_blocks", [])
scan_types = []
scan_numbers = []
scan_ids = []
status = item.get("status", "")
for request_block in blocks:
scan_type = request_block.get("content", {}).get("scan_type", "")
@@ -65,13 +150,25 @@ class BECQueue(BECWidget, QWidget):
scan_number = request_block.get("scan_number", "")
if scan_number:
scan_numbers.append(str(scan_number))
scan_id = request_block.get("scan_id", "")
if scan_id:
scan_ids.append(scan_id)
if scan_types:
scan_types = ", ".join(scan_types)
if scan_numbers:
scan_numbers = ", ".join(scan_numbers)
self.set_row(index, scan_numbers, scan_types, status)
if scan_ids:
scan_ids = ", ".join(scan_ids)
self.set_row(index, scan_numbers, scan_types, status, scan_ids)
busy = (
False
if all(item.get("status") in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
else True
)
self.set_global_state("warning" if busy else "default")
self.queue_busy.emit(busy)
def format_item(self, content: str) -> QTableWidgetItem:
def format_item(self, content: str, status=False) -> QTableWidgetItem:
"""
Format the content of the table item.
@@ -85,9 +182,17 @@ class BECQueue(BECWidget, QWidget):
content = ""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
# item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if status:
try:
color = self.status_colors.get(content, "black") # Default to black if not found
item.setForeground(QColor(color))
except:
return item
return item
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
def set_row(self, index: int, scan_number: str, scan_type: str, status: str, scan_id: str):
"""
Set the row of the table.
@@ -97,10 +202,42 @@ class BECQueue(BECWidget, QWidget):
scan_type (str): The scan type.
status (str): The status.
"""
abort_button = self._create_abort_button(scan_id)
abort_button.button.clicked.connect(self.delete_selected_row)
self.table.setItem(index, 0, self.format_item(scan_number))
self.table.setItem(index, 1, self.format_item(scan_type))
self.table.setItem(index, 2, self.format_item(status))
self.table.setItem(index, 2, self.format_item(status, status=True))
self.table.setCellWidget(index, 3, abort_button)
def _create_abort_button(self, scan_id: str) -> AbortButton:
"""
Create an abort button with styling for BEC Queue widget for certain scan_id.
Args:
scan_id(str): The scan id to abort.
Returns:
AbortButton: The abort button.
"""
abort_button = AbortButton(scan_id=scan_id)
abort_button.button.setText("")
abort_button.button.setIcon(
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
)
abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ")
abort_button.button.setFlat(True)
return abort_button
def delete_selected_row(self):
button = self.sender()
row = self.table.indexAt(button.pos()).row()
self.table.removeRow(row)
button.deleteLater()
def reset_content(self):
"""
@@ -108,7 +245,7 @@ class BECQueue(BECWidget, QWidget):
"""
self.table.setRowCount(1)
self.set_row(0, "", "", "")
self.set_row(0, "", "", "", "")
if __name__ == "__main__": # pragma: no cover

View File

@@ -13,8 +13,8 @@ from bec_lib.utils.import_utils import lazy_import_from
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
@@ -64,7 +64,7 @@ class BECServiceStatusMixin(QObject):
self._service_update_timer.deleteLater()
class BECStatusBox(BECWidget, QWidget):
class BECStatusBox(BECWidget, CompactPopupWidget):
"""An autonomous widget to display the status of BEC services.
Args:
@@ -83,15 +83,13 @@ class BECStatusBox(BECWidget, QWidget):
def __init__(
self,
parent=None,
box_name: str = "BEC Server",
box_name: str = "BEC Servers",
client: BECClient = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.tree = QTreeWidget(self)
self.layout = QHBoxLayout(self)
CompactPopupWidget.__init__(self, parent=parent, layout=QHBoxLayout)
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
@@ -100,11 +98,13 @@ class BECStatusBox(BECWidget, QWidget):
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
self.bec_service_status = bec_service_status_mixin
self.label = box_name
self.tooltip = "BEC servers health status"
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
self.layout.addWidget(self.tree)
self.addWidget(self.tree)
def init_ui(self) -> None:
"""Init the UI for the BECStatusBox widget, should only take place once."""
@@ -121,6 +121,7 @@ class BECStatusBox(BECWidget, QWidget):
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.tree = QTreeWidget(self)
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
self.tree.setStyleSheet(
@@ -163,6 +164,7 @@ class BECStatusBox(BECWidget, QWidget):
status (BECStatus): The state of the core services.
"""
self.status_container[self.box_name]["info"].status = status
self.set_global_state("emergency" if status == "NOTCONNECTED" else "success")
self.service_update.emit(self.status_container[self.box_name]["info"])
def _update_status_container(
@@ -303,17 +305,15 @@ class BECStatusBox(BECWidget, QWidget):
return super().cleanup()
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
apply_theme("dark")
set_theme("dark")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -6,11 +6,13 @@ import os
from datetime import datetime
from bec_lib.utils.import_utils import lazy_import_from
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Slot
from qtpy.QtGui import QIcon
from qtpy.QtGui import QIcon, QPainter
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout, QWidget
import bec_widgets
from bec_widgets.utils.colors import get_accent_colors
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
@@ -21,11 +23,11 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = os.path.join(MODULE_PATH, "assets", "status_icons", "running.svg")
BUSY = os.path.join(MODULE_PATH, "assets", "status_icons", "refresh.svg")
IDLE = os.path.join(MODULE_PATH, "assets", "status_icons", "warning.svg")
ERROR = os.path.join(MODULE_PATH, "assets", "status_icons", "error.svg")
NOTCONNECTED = os.path.join(MODULE_PATH, "assets", "status_icons", "not_connected.svg")
RUNNING = "done_outline"
BUSY = "progress_activity"
IDLE = "progress_activity"
ERROR = "emergency_home"
NOTCONNECTED = "signal_disconnected"
class StatusItem(QWidget):
@@ -43,13 +45,13 @@ class StatusItem(QWidget):
raise ValueError(
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
)
self.accent_colors = get_accent_colors()
self.config = config
self.parent = parent
self.layout = None
self._label = None
self._icon = None
self.icon_size = (24, 24)
self._popup_label_ref = {}
self.init_ui()
@@ -97,8 +99,15 @@ class StatusItem(QWidget):
def set_status(self) -> None:
"""Set the status icon for the status item widget."""
icon_path = IconsEnum[self.config.status].value
icon = QIcon(icon_path)
status = self.config.status
if status in ["RUNNING", "BUSY"]:
color = self.accent_colors.success
elif status == "IDLE":
color = self.accent_colors.warning
elif status in ["ERROR", "NOTCONNECTED"]:
color = self.accent_colors.emergency
icon = QIcon(material_icon(IconsEnum[status].value, filled=True, color=color))
self._icon.setPixmap(icon.pixmap(*self.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -132,6 +141,7 @@ class StatusItem(QWidget):
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
metrics_text += f"<b>BEC_LIB VERSION:</b> {self.config.info['version']}<br>"
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":

View File

@@ -11,7 +11,9 @@ class AbortButton(BECWidget, QWidget):
ICON_NAME = "cancel"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
def __init__(
self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None
):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
@@ -25,17 +27,19 @@ class AbortButton(BECWidget, QWidget):
if toolbar:
icon = material_icon("cancel", color="#666666", filled=True)
self.button = QToolButton(icon=icon)
self.button.triggered.connect(self.abort_scan)
self.button.setToolTip("Abort the scan")
else:
self.button = QPushButton()
self.button.setText("Abort")
self.button.setStyleSheet(
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.abort_scan)
self.button.clicked.connect(self.abort_scan)
self.layout.addWidget(self.button)
self.scan_id = scan_id
@SafeSlot()
def abort_scan(
self,
@@ -46,4 +50,8 @@ class AbortButton(BECWidget, QWidget):
Args:
scan_id(str|None): The scan id to abort. If None, the current scan will be aborted.
"""
self.queue.request_scan_abortion()
if self.scan_id is not None:
print(f"Aborting scan with scan_id: {self.scan_id}")
self.queue.request_scan_abortion(scan_id=self.scan_id)
else:
self.queue.request_scan_abortion()

View File

@@ -1,13 +1,13 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class ResetButton(BECWidget, QWidget):
"""A button that reset the scan queue."""
"""A button that resets the scan queue."""
ICON_NAME = "restart_alt"
@@ -23,20 +23,37 @@ class ResetButton(BECWidget, QWidget):
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("restart_alt", color="#F19E39", filled=True)
icon = material_icon(
"restart_alt", color="#F19E39", filled=True, convert_to_pixmap=False
)
self.button = QToolButton(icon=icon)
self.button.triggered.connect(self.reset_queue)
self.button.setToolTip("Reset the scan queue")
else:
self.button = QPushButton()
self.button.setText("Reset Queue")
self.button.setStyleSheet(
"background-color: #F19E39; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.reset_queue)
self.button.clicked.connect(self.confirm_reset_queue)
self.layout.addWidget(self.button)
@SafeSlot()
def confirm_reset_queue(self):
"""Prompt the user to confirm the queue reset."""
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Confirm Reset")
msg_box.setText(
"Are you sure you want to reset the scan queue? This action cannot be undone."
)
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.No)
if msg_box.exec_() == QMessageBox.Yes:
self.reset_queue()
@SafeSlot()
def reset_queue(self):
"""Stop the scan."""
"""Reset the scan queue."""
self.queue.request_queue_reset()

View File

@@ -23,16 +23,16 @@ class ResumeButton(BECWidget, QWidget):
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("resume", color="#2793e8", filled=True)
icon = material_icon("resume", color="#2793e8", filled=True, convert_to_pixmap=False)
self.button = QToolButton(icon=icon)
self.button.triggered.connect(self.continue_scan)
self.button.setToolTip("Resume the scan queue")
else:
self.button = QPushButton()
self.button.setText("Resume")
self.button.setStyleSheet(
"background-color: #2793e8; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.continue_scan)
self.button.clicked.connect(self.continue_scan)
self.layout.addWidget(self.button)

View File

@@ -3,24 +3,52 @@ from __future__ import annotations
from typing import Literal
import pyqtgraph as pg
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
class ColorButton(pg.ColorButton):
class ColorButton(QWidget):
"""
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
Patches event loop of the ColorDialog, if opened in another QDialog.
"""
color_selected = Signal(str)
ICON_NAME = "colors"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def selectColor(self):
self.origColor = self.color()
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.button = pg.ColorButton()
self.button.setFlat(True)
self.button.clicked.connect(self.select_color)
self.layout.addWidget(self.button)
@SafeSlot()
def select_color(self):
self.origColor = self.button.color()
self.button.colorDialog.setCurrentColor(self.button.color())
self.button.colorDialog.open()
self.button.colorDialog.exec()
self.color_selected.emit(self.button.color().name())
@SafeSlot(str)
def set_color(self, color: tuple | str):
"""
Set the color of the button.
Args:
color(tuple|str): The color to set.
"""
self.button.setColor(color)
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
"""
@@ -33,13 +61,13 @@ class ColorButton(pg.ColorButton):
tuple|str: The color in the specified format.
"""
if format == "RGBA":
return self.color().getRgb()
return self.button.color().getRgb()
if format == "HEX":
return self.color().name()
return self.button.color().name()
def cleanup(self):
"""
Clean up the ColorButton.
"""
self.colorDialog.close()
self.colorDialog.deleteLater()
self.button.colorDialog.close()
self.button.colorDialog.deleteLater()

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.colormap_widget.colormap_widget import BECColorMapWidget
DOM_XML = """
<ui language='c++'>
<widget class='BECColorMapWidget' name='bec_color_map_widget'>
</widget>
</ui>
"""
class BECColorMapWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECColorMapWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(BECColorMapWidget.ICON_NAME)
def includeFile(self):
return "bec_color_map_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECColorMapWidget"
def toolTip(self):
return "BECColorMapWidget"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,73 @@
from pyqtgraph.widgets.ColorMapButton import ColorMapButton
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget
class BECColorMapWidget(BECWidget, QWidget):
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
USER_ACCESS = ["colormap"]
def __init__(self, parent=None, cmap: str = "magma"):
super().__init__()
QWidget.__init__(self, parent=parent)
# Create the ColorMapButton
self.button = ColorMapButton()
# Set the size policy and minimum width
size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
self.button.setSizePolicy(size_policy)
self.button.setMinimumWidth(100)
self.button.setMinimumHeight(30)
# Create the layout
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.button)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
# Set the initial colormap
self.button.setColorMap(cmap)
self._cmap = cmap
# Connect the signal
self.button.sigColorMapChanged.connect(self.colormap_changed)
@Property(str)
def colormap(self):
"""Get the current colormap name."""
return self._cmap
@colormap.setter
def colormap(self, name):
"""Set the colormap by name."""
if self._cmap != name:
if Colors.validate_color_map(name, return_error=False) is False:
return
self.button.setColorMap(name)
self._cmap = name
self.colormap_changed_signal.emit(name)
@Slot()
def colormap_changed(self):
"""
Emit the colormap changed signal with the current colormap selected in the button.
"""
cmap = self.button.colorMap().name
self._cmap = cmap
self.colormap_changed_signal.emit(cmap)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = BECColorMapWidget()
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.colormap_widget.bec_color_map_widget_plugin import (
BECColorMapWidgetPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECColorMapWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,24 +1,24 @@
"""
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
embedded like any other Qt widget.
BECConsole is a Qt widget that runs a Bash shell.
BECConsole is powered by Pyte, a Python based terminal emulator
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
"""
import collections
import fcntl
import html
import os
import pty
import subprocess
import sys
import threading
import pyte
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QSize, QSocketNotifier, Qt
from qtpy.QtCore import Property as pyqtProperty
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QTextCursor
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
@@ -137,13 +137,52 @@ def QtKeyToAscii(event):
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
def __init__(self, stdin_fd, cols, rows, historyLength):
super().__init__(cols, rows, historyLength, ratio=1 / rows)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request for example"""
os.write(self._fd, data.encode("utf-8"))
"""Response to CPR request (for example),
this can be for other requests
"""
try:
os.write(self._fd, data.encode("utf-8"))
except (IOError, OSError):
pass
def resize(self, lines, columns):
lines = lines or self.lines
columns = columns or self.columns
if lines == self.lines and columns == self.columns:
return # No changes.
self.dirty.clear()
self.dirty.update(range(lines))
self.save_cursor()
if lines < self.lines:
if lines <= self.cursor.y:
nlines_to_move_up = self.lines - lines
for i in range(nlines_to_move_up):
line = self.buffer[i] # .pop(0)
self.history.top.append(line)
self.cursor_position(0, 0)
self.delete_lines(nlines_to_move_up)
self.restore_cursor()
self.cursor.y -= nlines_to_move_up
else:
self.restore_cursor()
self.lines, self.columns = lines, columns
self.history = History(
self.history.top,
self.history.bottom,
1 / self.lines,
self.history.size,
self.history.position,
)
self.set_margins()
class Backend(QtCore.QObject):
@@ -155,17 +194,17 @@ class Backend(QtCore.QObject):
"""
# Signals to communicate with ``_TerminalWidget``.
startWork = pyqtSignal()
dataReady = pyqtSignal(object)
processExited = pyqtSignal()
def __init__(self, fd, numColumns, numLines):
def __init__(self, fd, cols, rows):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, numColumns, numLines, 10000)
self.screen = Screen(self.fd, cols, rows, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
@@ -174,12 +213,14 @@ class Backend(QtCore.QObject):
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify the main applet.
Poll the Bash output, run it through Pyte, and notify
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
self.processExited.emit()
self.notifier.setEnabled(False)
return
# Feed output into Pyte's state machine and send the new screen
@@ -188,93 +229,256 @@ class Backend(QtCore.QObject):
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QScrollArea):
class BECConsole(QtWidgets.QWidget):
"""Container widget for the terminal text area"""
def __init__(self, parent=None, numLines=50, numColumns=125):
ICON_NAME = "terminal"
def __init__(self, parent=None, cols=132):
super().__init__(parent)
self.innerWidget = QtWidgets.QWidget(self)
QHBoxLayout(self.innerWidget)
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
self.term = _TerminalWidget(self, cols, rows=43)
self.scroll_bar = QScrollBar(Qt.Vertical, self)
# self.scroll_bar.hide()
layout = QHBoxLayout(self)
layout.addWidget(self.term)
layout.addWidget(self.scroll_bar)
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
layout.setContentsMargins(0, 0, 0, 0)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.innerWidget.layout().addWidget(self.term)
pal = QPalette()
self.set_bgcolor(pal.window().color())
self.set_fgcolor(pal.windowText().color())
self.term.set_scroll_bar(self.scroll_bar)
self.set_cmd("bec --nogui")
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
self.innerWidget.layout().addWidget(self.scroll_bar)
self._check_designer_timer = QTimer()
self._check_designer_timer.timeout.connect(self.check_designer)
self._check_designer_timer.start(1000)
self.term.set_scroll(self.scroll_bar)
def minimumSizeHint(self):
size = self.term.sizeHint()
size.setWidth(size.width() + self.scroll_bar.width())
return size
self.setWidget(self.innerWidget)
def sizeHint(self):
return self.minimumSizeHint()
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
def check_designer(self, calls={"n": 0}):
calls["n"] += 1
if self.term.fd is not None:
# already started
self._check_designer_timer.stop()
elif self.window().windowTitle().endswith("[Preview]"):
# assuming Designer preview -> start
self._check_designer_timer.stop()
self.term.start()
elif calls["n"] >= 3:
# assuming not in Designer -> stop checking
self._check_designer_timer.stop()
def get_rows(self):
return self.term.rows
def set_rows(self, rows):
self.term.rows = rows
self.adjustSize()
self.updateGeometry()
def get_cols(self):
return self.term.cols
def set_cols(self, cols):
self.term.cols = cols
self.adjustSize()
self.updateGeometry()
def get_bgcolor(self):
return QColor.fromString(self.term.bg_color)
def set_bgcolor(self, color):
self.term.bg_color = color.name(QColor.HexRgb)
def get_fgcolor(self):
return QColor.fromString(self.term.fg_color)
def set_fgcolor(self, color):
self.term.fg_color = color.name(QColor.HexRgb)
def get_cmd(self):
return self.term._cmd
def set_cmd(self, cmd):
self.term._cmd = cmd
if self.term.fd is None:
# not started yet
self.term.clear()
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
def start(self, deactivate_ctrl_d=True):
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text):
"""Push some text to the terminal"""
return self.term.push(text)
cols = pyqtProperty(int, get_cols, set_cols)
rows = pyqtProperty(int, get_rows, set_rows)
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
cmd = pyqtProperty(str, get_cmd, set_cmd)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
def __init__(self, parent, numColumns=125, numLines=50, **kwargs):
super().__init__(parent)
def __init__(self, parent, cols=125, rows=50, **kwargs):
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
self.lock = threading.Lock()
# command to execute
self._cmd = None
self._cmd = ""
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Default colors
pal = QPalette()
self._fg_color = pal.text().color().name()
self._bg_color = pal.base().color().name()
# Specify the terminal size in terms of lines and columns.
self.numLines = numLines
self.numColumns = numColumns
self.output = [""] * numLines
self._rows = rows
self._cols = cols
self.output = collections.deque()
super().__init__(parent)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_bar = None
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
fmt = QtGui.QFontMetrics(self.font())
self._char_width = fmt.width("w")
self._char_height = fmt.height()
self.setCursorWidth(self._char_width)
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
char_width = fmt.width("w")
self.setCursorWidth(char_width)
def start(self, deactivate_ctrl_d=False):
self.adjustSize()
self.updateGeometry()
self.update_stylesheet()
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, hexcolor):
self._bg_color = hexcolor
self.update_stylesheet()
@property
def fg_color(self):
return self._fg_color
@fg_color.setter
def fg_color(self, hexcolor):
self._fg_color = hexcolor
self.update_stylesheet()
def update_stylesheet(self):
self.setStyleSheet(
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
)
@property
def rows(self):
return self._rows
@rows.setter
def rows(self, rows: int):
if self.backend is None:
# not initialized yet, ok to change
self._rows = rows
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change rows after console is started.")
@property
def cols(self):
return self._cols
@cols.setter
def cols(self, cols: int):
if self.fd is None:
# not initialized yet, ok to change
self._cols = cols
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change cols after console is started.")
def start(self, deactivate_ctrl_d: bool = False):
self._deactivate_ctrl_d = deactivate_ctrl_d
# Start the Bash process
self.fd = self.forkShell()
self.update_term_size()
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.numColumns, self.numLines)
self.backend.dataReady.connect(self.dataReady)
# Start the Bash process
self.fd = self.fork_shell()
if self.fd:
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.cols, self.rows)
self.backend.dataReady.connect(self.data_ready)
self.backend.processExited.connect(self.process_exited)
else:
self.process_exited()
def process_exited(self):
self.fd = None
self.clear()
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
self.setReadOnly(True)
def data_ready(self, screen):
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
This method is triggered via a signal from ``Backend``.
"""
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def minimumSizeHint(self):
width = self._char_width * self.numColumns
height = self._char_height * self.numLines
return QSize(width, height + 20)
"""Return minimum size for current cols and rows"""
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
width = char_width * self.cols
height = char_height * self.rows
return QSize(width, height)
def set_scroll(self, scroll):
self.scroll = scroll
self.scroll.setMinimum(0)
self.scroll.valueChanged.connect(self.scroll_value_change)
def sizeHint(self):
return self.minimumSizeHint()
def scroll_value_change(self, value, old={"value": 0}):
def set_scroll_bar(self, scroll_bar):
self.scroll_bar = scroll_bar
self.scroll_bar.setMinimum(0)
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": -1}):
if self.backend is None:
return
if old["value"] == -1:
old["value"] = self.scroll_bar.maximum()
if value <= old["value"]:
# scroll up
# value is number of lines from the start
@@ -288,13 +492,35 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.dataReady(self.backend.screen, reset_scroll=False)
self.redraw_screen()
def adjust_scroll_bar(self):
sb = self.scroll_bar
sb.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
sb.setMaximum(tmp if tmp > 0 else 0)
sb.setSliderPosition(tmp if tmp > 0 else 0)
# if tmp > 0:
# # show scrollbar, but delayed - prevent recursion with widget size change
# QTimer.singleShot(0, scrollbar.show)
# else:
# QTimer.singleShot(0, scrollbar.hide)
sb.valueChanged.connect(self.scroll_value_change)
def write(self, data):
try:
os.write(self.fd, data)
except (IOError, OSError):
self.process_exited()
@Slot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
if self.fd is None:
# not started
return
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
@@ -311,15 +537,17 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
os.write(self.fd, code)
self.write(code)
def push(self, text):
"""
Write 'text' to terminal
"""
os.write(self.fd, text.encode("utf-8"))
self.write(text.encode("utf-8"))
def contextMenuEvent(self, event):
if self.fd is None:
return
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
@@ -341,7 +569,20 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def move_cursor(self):
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
def mouseReleaseEvent(self, event):
if self.fd is None:
return
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
@@ -355,34 +596,34 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', make cursor going to end
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
# a simple 'click', move scrollbar to end
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
self.move_cursor()
return None
return super().mouseReleaseEvent(event)
def dataReady(self, screenData, reset_scroll=True):
def redraw_screen(self):
"""
Render the new screen as text into the widget.
Render the screen as formatted text into the widget.
"""
screen = self.backend.screen
This method is triggered via a signal from ``Backend``.
"""
with self.lock:
# Clear the widget
# Clear the widget
if screen.dirty:
self.clear()
while len(self.output) < (max(screen.dirty) + 1):
self.output.append("")
while len(self.output) > (max(screen.dirty) + 1):
self.output.pop()
# Prepare the HTML output
for line_no in screenData.dirty:
for line_no in screen.dirty:
line = text = ""
style = old_style = ""
for ch in screenData.buffer[line_no].values():
old_idx = 0
for idx, ch in screen.buffer[line_no].items():
text += " " * (idx - old_idx - 1)
old_idx = idx
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
@@ -396,45 +637,47 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
# do a check at the cursor position:
# it is possible x pos > output line length,
# for example if last escape codes are "cursor forward" past end of text,
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
if line_no == screen.cursor.y:
llen = len(screen.buffer[line_no])
if llen < screen.cursor.x:
line += " " * (screen.cursor.x - llen)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
# done updates, all clean
screenData.dirty.clear()
# did updates, all clean
screen.dirty.clear()
# Activate cursor
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
def update_term_size(self):
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
self._cols = int(self.width() / char_width)
self._rows = int(self.height() / char_height)
# manage scroll
if reset_scroll:
self.scroll.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
self.scroll.setMaximum(tmp if tmp > 0 else 0)
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
self.scroll.valueChanged.connect(self.scroll_value_change)
# def resizeEvent(self, event):
# with self.lock:
# self.numColumns = int(self.width() / self._char_width)
# self.numLines = int(self.height() / self._char_height)
# self.output = [""] * self.numLines
# print("RESIZING TO", self.numColumns, "x", self.numLines)
# self.backend.screen.resize(self.numLines, self.numColumns)
def resizeEvent(self, event):
self.update_term_size()
if self.fd:
self.backend.screen.resize(self._rows, self._cols)
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def wheelEvent(self, event):
if not self.fd:
return
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.dataReady(self.backend.screen, reset_scroll=False)
self.redraw_screen()
def forkShell(self):
def fork_shell(self):
"""
Fork the current process and execute bec in shell.
"""
@@ -443,32 +686,30 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
except (IOError, OSError):
return False
if pid == 0:
# Safe way to make it work under BSD and Linux
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
os.putenv("COLUMNS", str(self.cols))
os.putenv("LINES", str(self.rows))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if not self._cmd:
self._cmd = os.environ["SHELL"]
cmd = self._cmd
if isinstance(cmd, str):
cmd = cmd.split()
try:
os.putenv("COLUMNS", str(self.numColumns))
os.putenv("LINES", str(self.numLines))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if isinstance(self._cmd, str):
os.execvp(self._cmd, self._cmd)
else:
os.execvp(self._cmd[0], self._cmd)
# print "child_pid", child_pid, sts
os.execvp(cmd[0], cmd)
except (IOError, OSError):
pass
# self.proc_finish(sid)
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
print("Spawned Bash shell (PID {})".format(pid))
return fd
@@ -478,17 +719,13 @@ if __name__ == "__main__":
from qtpy import QtGui, QtWidgets
# Terminal size in characters.
numLines = 25
numColumns = 100
# Create the Qt application and QBash instance.
# Create the Qt application and console.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole ({}x{})".format(numColumns, numLines)
title = "BECConsole"
mainwin.setWindowTitle(title)
console = BECConsole(mainwin, numColumns, numLines)
console = BECConsole(mainwin)
mainwin.setCentralWidget(console)
console.start()

View File

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

View File

@@ -0,0 +1,58 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.console.console import BECConsole
DOM_XML = """
<ui language='c++'>
<widget class='BECConsole' name='bec_console'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Console"
def icon(self):
return designer_material_icon(BECConsole.ICON_NAME)
def includeFile(self):
return "bec_console"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECConsole"
def toolTip(self):
return "A terminal-like vt100 widget."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.console.console_plugin import BECConsolePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,184 @@
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class DapComboBox(BECWidget, QWidget):
"""
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
default: Default device name.
"""
ICON_NAME = "data_exploration"
USER_ACCESS = ["select_y_axis", "select_x_axis", "select_fit_model"]
### Signals ###
# Signal to emit a new dap_config: (x_axis, y_axis, fit_model). Can be used to add a new DAP process
# in the BECWaveformWidget using its add_dap method. The signal is emitted when the user selects a new
# fit model, but only if x_axis and y_axis are set.
new_dap_config = Signal(str, str, str)
# Signal to emit the name of the updated x_axis
x_axis_updated = Signal(str)
# Signal to emit the name of the updated y_axis
y_axis_updated = Signal(str)
# Signal to emit the name of the updated fit model
fit_model_updated = Signal(str)
def __init__(
self, parent=None, client=None, gui_id: str | None = None, default_fit: str | None = None
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
self.layout.setContentsMargins(0, 0, 0, 0)
self._available_models = None
self._x_axis = None
self._y_axis = None
self.populate_fit_model_combobox()
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit)
def select_default_fit(self, default_fit: str | None):
"""Set the default fit model.
Args:
default_fit(str): Default fit model.
"""
if self._validate_dap_model(default_fit):
self.select_fit_model(default_fit)
else:
self.select_fit_model("GaussianModel")
@property
def available_models(self):
"""Available models property."""
return self._available_models
@available_models.setter
def available_models(self, available_models: list[str]):
"""Set the available models.
Args:
available_models(list[str]): Available models.
"""
self._available_models = available_models
@Property(str)
def x_axis(self):
"""X axis property."""
return self._x_axis
@x_axis.setter
def x_axis(self, x_axis: str):
"""Set the x axis.
Args:
x_axis(str): X axis.
"""
# TODO add validator for x axis -> Positioner? or also device (must be monitored)!!
self._x_axis = x_axis
self.x_axis_updated.emit(x_axis)
@Property(str)
def y_axis(self):
"""Y axis property."""
# TODO add validator for y axis -> Positioner & Device? Must be a monitored device!!
return self._y_axis
@y_axis.setter
def y_axis(self, y_axis: str):
"""Set the y axis.
Args:
y_axis(str): Y axis.
"""
self._y_axis = y_axis
self.y_axis_updated.emit(y_axis)
def _update_current_fit(self, fit_name: str):
"""Update the current fit."""
self.fit_model_updated.emit(fit_name)
if self.x_axis is not None and self.y_axis is not None:
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
@Slot(str)
def select_x_axis(self, x_axis: str):
"""Slot to update the x axis.
Args:
x_axis(str): X axis.
"""
self.x_axis = x_axis
self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str)
def select_y_axis(self, y_axis: str):
"""Slot to update the y axis.
Args:
y_axis(str): Y axis.
"""
self.y_axis = y_axis
self._update_current_fit(self.fit_model_combobox.currentText())
@Slot(str)
def select_fit_model(self, fit_name: str | None):
"""Slot to update the fit model.
Args:
default_device(str): Default device name.
"""
if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.")
self.fit_model_combobox.setCurrentText(fit_name)
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)
def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model.
Args:
model(str): Model name.
"""
if model is None:
return False
if model not in self.available_models:
return False
return True
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
app.exec_()

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dap_combo_box.dap_combo_box import DapComboBox
DOM_XML = """
<ui language='c++'>
<widget class='DapComboBox' name='dap_combo_box'>
</widget>
</ui>
"""
class DapComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DapComboBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(DapComboBox.ICON_NAME)
def includeFile(self):
return "dap_combo_box"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DapComboBox"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.dap_combo_box.dap_combo_box_plugin import DapComboBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(DapComboBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Slot
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
@@ -18,7 +20,7 @@ class DarkModeButton(BECWidget, QWidget):
gui_id: str | None = None,
toolbar: bool = False,
) -> None:
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, theme_update=True)
QWidget.__init__(self, parent)
self._dark_mode_enabled = False
@@ -39,6 +41,17 @@ class DarkModeButton(BECWidget, QWidget):
self.setLayout(self.layout)
self.setFixedSize(40, 40)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
Args:
theme(str, optional): The theme to be applied.
"""
self.dark_mode_enabled = theme == "dark"
self.update_mode_button()
def _get_qapp_dark_mode_state(self) -> bool:
"""
Get the dark mode state from the QApplication.
@@ -47,7 +60,7 @@ class DarkModeButton(BECWidget, QWidget):
bool: True if dark mode is enabled, False otherwise.
"""
qapp = QApplication.instance()
if hasattr(qapp, "theme") and qapp.theme["theme"] == "dark":
if hasattr(qapp, "theme") and qapp.theme.theme == "dark":
return True
return False

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from qtpy.QtCore import QMimeData, Qt
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
@@ -9,6 +10,8 @@ from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
if TYPE_CHECKING:
from qtpy.QtGui import QMouseEvent
logger = bec_logger.logger
class DeviceItem(QWidget):
def __init__(self, device: str) -> None:
@@ -37,7 +40,7 @@ class DeviceItem(QWidget):
drag.exec_(Qt.MoveAction)
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
print("Double Clicked")
logger.debug("Double Clicked")
# TODO: Implement double click action for opening the device properties dialog
return super().mouseDoubleClickEvent(event)

View File

@@ -31,7 +31,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "Device Control"
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(DeviceComboBox.ICON_NAME)

View File

@@ -1,86 +1,163 @@
from typing import TYPE_CHECKING
from bec_lib.callback_handler import EventType
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputConfig
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
class DeviceComboBox(DeviceInputBase, QComboBox):
"""
Line edit widget for device input with autocomplete for device names.
Combobox widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
ICON_NAME = "list_alt"
device_selected = Signal(str)
device_config_update = Signal()
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | None = None,
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.setMinimumSize(125, 26)
self.populate_combobox()
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
# Set available devices if passed
if available_devices is not None:
self.set_available_devices(available_devices)
# Set readout priority filter default is all
if readout_priority_filter is not None:
self.set_readout_priority_filter(readout_priority_filter)
else:
self.set_readout_priority_filter(
[
ReadoutPriority.MONITORED,
ReadoutPriority.BASELINE,
ReadoutPriority.ASYNC,
ReadoutPriority.CONTINUOUS,
ReadoutPriority.ON_REQUEST,
]
)
# Device filter default is None
if device_filter is not None:
self.set_device_filter(device_filter)
# Set default device if passed
if default is not None:
self.set_default_device(default)
self.set_device(default)
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(self.update_devices_from_filters)
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
def set_device_filter(self, device_filter: str):
def on_device_update(self, action: str, content: dict) -> None:
"""
Set the device filter.
Callback for device update events. Triggers the device_update signal.
Args:
device_filter(str): Device filter, name of the device class.
action (str): The action that triggered the event.
content (dict): The content of the config update.
"""
super().set_device_filter(device_filter)
self.populate_combobox()
if action in ["add", "remove", "reload"]:
self.device_config_update.emit()
def set_default_device(self, default_device: str):
def cleanup(self):
"""Cleanup the widget."""
if self._callback_id is not None:
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
def get_current_device(self) -> object:
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
super().set_default_device(default_device)
self.setCurrentText(default_device)
def populate_combobox(self):
"""Populate the combobox with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.clear()
self.addItems(self.devices)
def get_device(self) -> object:
"""
Get the selected device object.
Get the current device object based on the current value.
Returns:
object: Device object.
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
device_name = self.currentText()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
dev_name = self.currentText()
return self.get_device_object(dev_name)
def paintEvent(self, event: QPaintEvent) -> None:
"""Extend the paint event to set the border color based on the validity of the input.
Args:
event (PySide6.QtGui.QPaintEvent) : Paint event.
"""
# logger.info(f"Received paint event: {event} in {self.__class__}")
super().paintEvent(event)
if self._is_valid_input is False and self.isEnabled() is True:
painter = QPainter(self)
pen = QPen()
pen.setWidth(2)
pen.setColor(self._accent_colors.emergency)
painter.setPen(pen)
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
painter.end()
@Slot(str)
def check_validity(self, input_text: str) -> None:
"""
Check if the current value is a valid device name.
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
else:
self._is_valid_input = False
self.update()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
combo = DeviceComboBox()
combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"]
layout.addWidget(combo)
widget.show()
app.exec_()

View File

@@ -1,13 +1,18 @@
from typing import TYPE_CHECKING
from bec_lib.callback_handler import EventType
from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QApplication, QCompleter, QLineEdit, QSizePolicy
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputConfig
logger = bec_logger.logger
class DeviceLineEdit(DeviceInputBase, QLineEdit):
@@ -19,11 +24,14 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
device_filter: Device filter, name of the device class from BECDeviceFilter and ReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
device_selected = Signal(str)
device_config_update = Signal()
ICON_NAME = "edit_note"
def __init__(
@@ -32,68 +40,137 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | list[str] | None = None,
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(client=client, config=config, gui_id=gui_id)
QLineEdit.__init__(self, parent=parent)
self.completer = QCompleter(self)
self.setCompleter(self.completer)
self.populate_completer()
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default is not None:
self.set_default_device(default)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
def set_device_filter(self, device_filter: str | list[str]):
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
# Set available devices if passed
if available_devices is not None:
self.set_available_devices(available_devices)
# Set readout priority filter default is all
if readout_priority_filter is not None:
self.set_readout_priority_filter(readout_priority_filter)
else:
self.set_readout_priority_filter(
[
ReadoutPriority.MONITORED,
ReadoutPriority.BASELINE,
ReadoutPriority.ASYNC,
ReadoutPriority.CONTINUOUS,
ReadoutPriority.ON_REQUEST,
]
)
# Device filter default is None
if device_filter is not None:
self.set_device_filter(device_filter)
# Set default device if passed
if default is not None:
self.set_device(default)
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(self.update_devices_from_filters)
self.textChanged.connect(self.check_validity)
self.check_validity(self.text())
def on_device_update(self, action: str, content: dict) -> None:
"""
Set the device filter.
Callback for device update events. Triggers the device_update signal.
Args:
device_filter (str | list[str]): Device filter, name of the device class.
action (str): The action that triggered the event.
content (dict): The content of the config update.
"""
super().set_device_filter(device_filter)
self.populate_completer()
if action in ["add", "remove", "reload"]:
self.device_config_update.emit()
def set_default_device(self, default_device: str):
def cleanup(self):
"""Cleanup the widget."""
if self._callback_id is not None:
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
def get_current_device(self) -> object:
"""
Set the default device.
Args:
default_device (str): Default device name.
"""
super().set_default_device(default_device)
self.setText(default_device)
def populate_completer(self):
"""Populate the completer with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.completer.setModel(self.create_completer_model(self.devices))
def create_completer_model(self, devices: list[str]):
"""Create a model for the completer."""
from qtpy.QtCore import QStringListModel
return QStringListModel(devices, self)
def get_device(self) -> object:
"""
Get the selected device object.
Get the current device object based on the current value.
Returns:
object: Device object.
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
device_name = self.text()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
dev_name = self.text()
return self.get_device_object(dev_name)
def paintEvent(self, event: QPaintEvent) -> None:
"""Extend the paint event to set the border color based on the validity of the input.
Args:
event (PySide6.QtGui.QPaintEvent) : Paint event.
"""
# logger.info(f"Received paint event: {event} in {self.__class__}")
super().paintEvent(event)
if self._is_valid_input is False and self.isEnabled() is True:
painter = QPainter(self)
pen = QPen()
pen.setWidth(2)
pen.setColor(self._accent_colors.emergency)
painter.setPen(pen)
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
painter.end()
@Slot(str)
def check_validity(self, input_text: str) -> None:
"""
Check if the current value is a valid device name.
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
else:
self._is_valid_input = False
self.update()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.signal_combobox.signal_combobox import SignalComboBox
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
line_edit = DeviceLineEdit()
line_edit.filter_to_positioner = True
signal_line_edit = SignalComboBox()
line_edit.textChanged.connect(signal_line_edit.set_device)
line_edit.set_available_devices(["samx", "samy", "samz"])
line_edit.set_device("samx")
layout.addWidget(line_edit)
layout.addWidget(signal_line_edit)
widget.show()
app.exec_()

View File

@@ -31,7 +31,7 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "Device Control"
return "BEC Input Widgets"
def icon(self):
return designer_material_icon(DeviceLineEdit.ICON_NAME)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional
from qtpy.QtCore import QSize
from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui
@@ -116,6 +117,7 @@ class BECDock(BECWidget, Dock):
"remove",
"attach",
"detach",
"close",
]
def __init__(
@@ -126,6 +128,7 @@ class BECDock(BECWidget, Dock):
name: str | None = None,
client=None,
gui_id: str | None = None,
temp: bool = False,
closable: bool = True,
**kwargs,
) -> None:
@@ -147,6 +150,8 @@ class BECDock(BECWidget, Dock):
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
self.temp = temp
def dropEvent(self, event):
source = event.source()
old_area = source.area
@@ -172,6 +177,24 @@ class BECDock(BECWidget, Dock):
else:
super().float()
if self.temp:
self.make_dock_temporary()
def make_dock_temporary(self):
"""
Make the dock temporary.
"""
from bec_widgets.widgets.dock import BECDockArea
self.orig_area.docks.pop(self.name(), None)
self.orig_area = BECDockArea()
self.area = self.orig_area
self.area.panels[self.name()] = self
self.config.parent_dock_area = self.area.gui_id
self.area.temporary = False
self.hide_title_bar()
@property
def widget_list(self) -> list[BECWidget]:
"""
@@ -333,3 +356,7 @@ class BECDock(BECWidget, Dock):
self.cleanup()
super().close()
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
if self.temp:
self.area.deleteLater()
self.deleteLater()

View File

@@ -278,6 +278,7 @@ class BECDockArea(BECWidget, QWidget):
name: str = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
relative_to: BECDock | None = None,
temporary: bool = False,
closable: bool = True,
floating: bool = False,
prefix: str = "dock",
@@ -294,6 +295,7 @@ class BECDockArea(BECWidget, QWidget):
name(str): The name of the dock to be displayed and for further references. Has to be unique.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
temp(bool): Whether the dock is temporary. Upon closing the dock is not returned to the parent dock area.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
prefix(str): The prefix for the dock name if no name is provided.
@@ -317,7 +319,7 @@ class BECDockArea(BECWidget, QWidget):
if position is None:
position = "bottom"
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock = BECDock(name=name, parent_dock_area=self, closable=closable, temp=temporary)
dock.config.position = position
self.config.docks[name] = dock.config
@@ -338,10 +340,19 @@ class BECDockArea(BECWidget, QWidget):
): # TODO still decide how initial instructions should be handled
self._instructions_visible = False
self.update()
if floating:
if floating or temporary:
dock.detach()
print("dock added")
return dock
# def add_temp_dock(self):
# area = BECDockArea()
# area.show()
# area.add_dock("dock1", widget="BECFigure")
def addDock(self, *args, **kwargs):
return self.add_dock(*args, **kwargs)
def detach_dock(self, dock_name: str) -> BECDock:
"""
Undock a dock from the dock area.
@@ -402,6 +413,11 @@ class BECDockArea(BECWidget, QWidget):
self.cleanup()
super().close()
def closeEvent(self, event):
print("close event called")
self.cleanup()
super().closeEvent(event)
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
@@ -412,4 +428,5 @@ if __name__ == "__main__":
set_theme("auto")
dock_area = BECDockArea()
dock_area.show()
dock_area.add_dock("dock1", widget="BECFigure", temporary=True)
app.exec_()

View File

@@ -7,6 +7,7 @@ from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
@@ -20,6 +21,8 @@ from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, Mo
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig
logger = bec_logger.logger
class FigureConfig(ConnectionConfig):
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
@@ -179,7 +182,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
try:
config = FigureConfig(**config)
except ValidationError as e:
print(f"Error in applying config: {e}")
logger.error(f"Error in applying config: {e}")
return
self.config = config
@@ -318,6 +321,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
self,
image,
monitor: str = None,
monitor_type: Literal["1d", "2d"] = "2d",
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
@@ -334,7 +338,13 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
data (np.ndarray): Custom data to display.
"""
if monitor is not None and data is None:
image.image(monitor=monitor, color_map=color_map, vrange=vrange, color_bar=color_bar)
image.image(
monitor=monitor,
monitor_type=monitor_type,
color_map=color_map,
vrange=vrange,
color_bar=color_bar,
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
@@ -352,6 +362,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
def image(
self,
monitor: str = None,
monitor_type: Literal["1d", "2d"] = "2d",
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
@@ -390,6 +401,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
image = self._init_image(
image=image,
monitor=monitor,
monitor_type=monitor_type,
color_bar=color_bar,
color_map=color_map,
data=data,

View File

@@ -31,6 +31,7 @@ class AxisSettings(SettingWidget):
# Top Box
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
self.ui.switch_outer_axes.checked = axis_config["outer_axes"]
# X Axis Box
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
@@ -63,6 +64,7 @@ class AxisSettings(SettingWidget):
@Slot()
def accept_changes(self):
title = WidgetIO.get_value(self.ui.plot_title)
outer_axes = self.ui.switch_outer_axes.checked
# X Axis
x_label = WidgetIO.get_value(self.ui.x_label)
@@ -86,3 +88,4 @@ class AxisSettings(SettingWidget):
y_lim=y_lim,
)
self.target_widget.set_grid(x_grid, y_grid)
self.target_widget.set_outer_axes(outer_axes)

View File

@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>417</width>
<height>250</height>
<width>427</width>
<height>270</height>
</rect>
</property>
<property name="minimumSize">
@@ -26,7 +26,28 @@
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
@@ -120,7 +141,7 @@
</layout>
</widget>
</item>
<item row="1" column="0">
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
@@ -214,22 +235,22 @@
</layout>
</widget>
</item>
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -5,11 +5,12 @@ 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 qtpy.QtCore import QThread
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.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 (
@@ -19,6 +20,8 @@ from bec_widgets.widgets.figure.plots.image.image_processor import (
)
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class ImageConfig(SubplotConfig):
images: dict[str, ImageItemConfig] = Field(
@@ -54,6 +57,7 @@ class BECImageShow(BECPlotBase):
"set_x_lim",
"set_y_lim",
"set_grid",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
@@ -76,6 +80,8 @@ class BECImageShow(BECPlotBase):
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.single_image = single_image
self.image_type = "device_monitor_2d"
self.scan_id = None
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
self._images = defaultdict(dict)
@@ -101,7 +107,7 @@ class BECImageShow(BECPlotBase):
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
"""
Find the widget by its gui_id.
Find the image item by its gui_id.
Args:
item_id(str): The gui_id of the widget.
@@ -130,7 +136,7 @@ class BECImageShow(BECPlotBase):
try:
config = ImageConfig(**config)
except ValidationError as e:
print(f"Validation error when applying config to BECImageShow: {e}")
logger.error(f"Validation error when applying config to BECImageShow: {e}")
return
self.config = config
self.plot_item.clear()
@@ -226,6 +232,7 @@ class BECImageShow(BECPlotBase):
def image(
self,
monitor: str,
monitor_type: Literal["1d", "2d"] = "2d",
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
@@ -239,6 +246,7 @@ class BECImageShow(BECPlotBase):
Args:
monitor(str): The name of the monitor to display.
monitor_type(Literal["1d","2d"]): The type of monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
@@ -247,7 +255,12 @@ class BECImageShow(BECPlotBase):
Returns:
BECImageItem: The image item.
"""
image_source = "device_monitor_2d"
if monitor_type == "1d":
image_source = "device_monitor_1d"
self.image_type = "device_monitor_1d"
elif monitor_type == "2d":
image_source = "device_monitor_2d"
self.image_type = "device_monitor_2d"
image_exits = self._check_image_id(monitor, self._images)
if image_exits:
@@ -288,7 +301,6 @@ class BECImageShow(BECPlotBase):
**kwargs,
):
image_source = "custom"
# image_source = "device_monitor_2d"
image_exits = self._check_image_id(name, self._images)
if image_exits:
@@ -512,9 +524,58 @@ class BECImageShow(BECPlotBase):
"""
data = msg["data"]
device = msg["device"]
image = self._images["device_monitor_2d"][device]
image.raw_data = data
self.process_image(device, image, data)
if self.image_type == "device_monitor_1d":
image = self._images["device_monitor_1d"][device]
current_scan_id = metadata.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.reset()
self.scan_id = current_scan_id
image.image_buffer_list = []
image.max_len = 0
image_buffer = self.adjust_image_buffer(image, data)
image.raw_data = image_buffer
self.process_image(device, image, image_buffer)
elif self.image_type == "device_monitor_2d":
image = self._images["device_monitor_2d"][device]
image.raw_data = data
self.process_image(device, image, data)
def adjust_image_buffer(self, image: BECImageItem, new_data: np.ndarray) -> np.ndarray:
"""
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
Args:
image: The image object (used to store buffer list and max_len).
new_data (np.ndarray): The new incoming 1D waveform data.
Returns:
np.ndarray: The updated image buffer with adjusted shapes.
"""
new_len = new_data.shape[0]
if not hasattr(image, "image_buffer_list"):
image.image_buffer_list = []
image.max_len = 0
if new_len > image.max_len:
image.max_len = new_len
for i in range(len(image.image_buffer_list)):
wf = image.image_buffer_list[i]
pad_width = image.max_len - wf.shape[0]
if pad_width > 0:
image.image_buffer_list[i] = np.pad(
wf, (0, pad_width), mode="constant", constant_values=0
)
image.image_buffer_list.append(new_data)
else:
pad_width = image.max_len - new_len
if pad_width > 0:
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
image.image_buffer_list.append(new_data)
image_buffer = np.array(image.image_buffer_list)
return image_buffer
@Slot(str, np.ndarray)
def update_image(self, device: str, data: np.ndarray):
@@ -525,7 +586,7 @@ class BECImageShow(BECPlotBase):
device(str): The name of the device.
data(np.ndarray): The data to be updated.
"""
image_to_update = self._images["device_monitor_2d"][device]
image_to_update = self._images[self.image_type][device]
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
@Slot(str, ImageStats)
@@ -536,7 +597,7 @@ class BECImageShow(BECPlotBase):
Args:
stats(ImageStats): The statistics of the image.
"""
image_to_update = self._images["device_monitor_2d"][device]
image_to_update = self._images[self.image_type][device]
if image_to_update.config.autorange:
image_to_update.auto_update_vrange(stats)
@@ -549,7 +610,7 @@ class BECImageShow(BECPlotBase):
data = image.raw_data
self.process_image(image_id, image, data)
def _connect_device_monitor_2d(self, monitor: str):
def _connect_device_monitor(self, monitor: str):
"""
Connect to the device monitor.
@@ -562,29 +623,36 @@ class BECImageShow(BECPlotBase):
except AttributeError:
previous_monitor = None
if previous_monitor and image_item.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(previous_monitor)
)
image_item.connected = False
if monitor and image_item.connected is False:
self.entry_validator.validate_monitor(monitor)
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
)
if self.image_type == "device_monitor_1d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
)
elif self.image_type == "device_monitor_2d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
)
image_item.set_monitor(monitor)
image_item.connected = True
def _add_image_object(
self, source: str, name: str, config: ImageItemConfig, data=None
) -> BECImageItem: # TODO fix types
) -> BECImageItem:
config.parent_id = self.gui_id
if self.single_image is True and len(self.images) > 0:
self.remove_image(0)
image = BECImageItem(config=config, parent_image=self)
self.plot_item.addItem(image)
self._images[source][name] = image
if source == "device_monitor_2d":
self._connect_device_monitor_2d(config.monitor)
self._connect_device_monitor(config.monitor)
self.config.images[name] = config
if data is not None:
image.setImage(data)
@@ -669,6 +737,9 @@ class BECImageShow(BECPlotBase):
"""
image = self.find_image_by_monitor(image_id)
if image:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(image.config.monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(image.config.monitor)
)
@@ -677,9 +748,9 @@ class BECImageShow(BECPlotBase):
"""
Clean up the widget.
"""
for monitor in self._images["device_monitor_2d"]:
for monitor in self._images[self.image_type]:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
)
self.images.clear()

View File

@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig
@@ -12,6 +13,8 @@ from bec_widgets.widgets.figure.plots.image.image_processor import ImageStats, P
if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
logger = bec_logger.logger
class ImageItemConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
@@ -133,7 +136,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
logger.warning(f"Warning: '{key}' is not a recognized property.")
def set_fft(self, enable: bool = False):
"""
@@ -304,7 +307,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
if vrange is not None:
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
self.color_bar.setImageItem(self)
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
self.parent_image.addItem(self.color_bar, row=1, col=1)
self.config.color_bar = "simple"
elif color_bar_style == "full":
# Setting histogram
@@ -318,7 +321,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
)
# Adding histogram to the layout
self.parent_image.addItem(self.color_bar) # , row=0, col=1)
self.parent_image.addItem(self.color_bar, row=1, col=1)
# save settings
self.config.color_bar = "full"

View File

@@ -6,6 +6,7 @@ from typing import Optional, Union
import numpy as np
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtCore, QtGui
@@ -17,6 +18,8 @@ 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
logger = bec_logger.logger
class MotorMapConfig(SubplotConfig):
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
@@ -101,7 +104,7 @@ class BECMotorMap(BECPlotBase):
try:
config = MotorMapConfig(**config)
except ValidationError as e:
print(f"Error in applying config: {e}")
logger.error(f"Error in applying config: {e}")
return
self.config = config
@@ -440,7 +443,7 @@ class BECMotorMap(BECPlotBase):
return limits
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
print(f"The device '{motor}' does not have defined limits.")
logger.error(f"The device '{motor}' does not have defined limits.")
return None
@Slot()

View File

@@ -2,13 +2,19 @@ from __future__ import annotations
from typing import Literal, Optional
import bec_qthemes
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
logger = bec_logger.logger
class AxisConfig(BaseModel):
@@ -27,6 +33,7 @@ class AxisConfig(BaseModel):
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
x_grid: bool = Field(False, description="Show grid on the x-axis.")
y_grid: bool = Field(False, description="Show grid on the y-axis.")
outer_axes: bool = Field(False, description="Show the outer axes of the plot widget.")
model_config: dict = {"validate_assignment": True}
@@ -44,6 +51,11 @@ class SubplotConfig(ConnectionConfig):
class BECViewBox(pg.ViewBox):
sigPaint = Signal()
def paint(self, painter, opt, widget):
super().paint(painter, opt, widget)
self.sigPaint.emit()
def itemBoundsChanged(self, item):
self._itemBoundsCache.pop(item, None)
@@ -71,6 +83,8 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"set_x_lim",
"set_y_lim",
"set_grid",
"set_outer_axes",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
@@ -92,12 +106,50 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.figure = parent_figure
# self.plot_item = self.addPlot(row=0, col=0)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self)
self.addItem(self.plot_item, row=0, col=0)
self.addItem(self.plot_item, row=1, col=0)
self.add_legend()
self.crosshair = None
self.fps_monitor = None
self.fps_label = None
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
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)
@Slot(str)
def _update_theme(self, theme: str):
"""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 widget.
Args:
theme(str, optional): The theme to be applied.
"""
palette = bec_qthemes.load_palette(theme)
text_pen = pg.mkPen(color=palette.text().color())
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:
"""
@@ -131,7 +183,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
logger.warning(f"Warning: '{key}' is not a recognized property.")
def apply_axis_config(self):
"""Apply the axis configuration to the plot widget."""
@@ -300,6 +352,17 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def set_outer_axes(self, show: bool = True):
"""
Set the outer axes of the plot widget.
Args:
show(bool): Show the outer axes.
"""
self.plot_item.showAxis("top", show)
self.plot_item.showAxis("right", show)
self.config.axis.outer_axes = show
def add_legend(self):
"""Add legend to the plot"""
self.plot_item.addLegend()
@@ -326,12 +389,16 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"""
self.plot_item.enableAutoRange(axis, enabled)
############################################################
###################### Crosshair ###########################
############################################################
def hook_crosshair(self) -> None:
"""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)
@@ -340,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)
@@ -364,6 +431,55 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.crosshair.clear_markers()
self.crosshair.update_markers()
############################################################
##################### FPS Counter ##########################
############################################################
def update_fps_label(self, fps: float) -> None:
"""
Update the FPS label.
Args:
fps(float): The frames per second.
"""
if self.fps_label:
self.fps_label.setText(f"FPS: {fps:.2f}")
def hook_fps_monitor(self):
"""Hook the FPS monitor to the plot."""
if self.fps_monitor is None:
# text_color = self.get_text_color()#TODO later
self.fps_monitor = FPSCounter(self.plot_item.vb) # text_color=text_color)
self.fps_label = pg.LabelItem(justify="right")
self.addItem(self.fps_label, row=0, col=0)
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
def unhook_fps_monitor(self, delete_label=True):
"""Unhook the FPS monitor from the plot."""
if self.fps_monitor is not None:
# Remove Monitor
self.fps_monitor.cleanup()
self.fps_monitor.deleteLater()
self.fps_monitor = None
if self.fps_label is not None and delete_label:
# Remove Label
self.removeItem(self.fps_label)
self.fps_label.deleteLater()
self.fps_label = None
def enable_fps_monitor(self, enable: bool = True):
"""
Enable the FPS monitor.
Args:
enable(bool): True to enable, False to disable.
"""
if enable and self.fps_monitor is None:
self.hook_fps_monitor()
elif not enable and self.fps_monitor is not None:
self.unhook_fps_monitor()
def export(self):
"""Show the Export Dialog of the plot widget."""
scene = self.plot_item.scene()
@@ -378,6 +494,9 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=False)
self.tick_item.cleanup()
self.arrow_item.cleanup()
item = self.plot_item
item.vb.menu.close()
item.vb.menu.deleteLater()

View File

@@ -1,3 +1,4 @@
# pylint: disable=too_many_lines
from __future__ import annotations
from collections import defaultdict
@@ -8,6 +9,7 @@ import pyqtgraph as pg
from bec_lib import messages
from bec_lib.device import ReadoutPriority
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal as pyqtSignal
@@ -15,6 +17,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.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 (
BECCurve,
@@ -23,6 +27,8 @@ from bec_widgets.widgets.figure.plots.waveform.waveform_curve import (
SignalData,
)
logger = bec_logger.logger
class Waveform1DConfig(SubplotConfig):
color_palette: Optional[str] = Field(
@@ -49,8 +55,8 @@ class BECWaveform(BECPlotBase):
"_config_dict",
"plot",
"add_dap",
"set_x",
"get_dap_params",
"set_x",
"remove_curve",
"scan_history",
"curves",
@@ -66,18 +72,25 @@ class BECWaveform(BECPlotBase):
"set_y_lim",
"set_grid",
"set_colormap",
"enable_scatter",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
"clear_all",
"set_legend_label_size",
"toggle_roi",
"select_roi",
]
scan_signal_update = pyqtSignal()
async_signal_update = pyqtSignal()
dap_params_update = pyqtSignal(dict)
dap_summary_update = pyqtSignal(dict)
dap_params_update = pyqtSignal(dict, dict)
dap_summary_update = pyqtSignal(dict, dict)
autorange_signal = pyqtSignal()
new_scan = pyqtSignal()
roi_changed = pyqtSignal(tuple)
roi_active = pyqtSignal(bool)
request_dap_refresh = pyqtSignal()
def __init__(
self,
@@ -97,6 +110,9 @@ class BECWaveform(BECPlotBase):
self.old_scan_id = None
self.scan_id = None
self.scan_item = None
self._roi_region = None
self.roi_select = None
self._accent_colors = get_accent_colors()
self._x_axis_mode = {
"name": None,
"entry": None,
@@ -127,6 +143,63 @@ class BECWaveform(BECPlotBase):
self.add_legend()
self.apply_config(self.config)
@Slot(bool)
def toggle_roi(self, toggled: bool) -> None:
"""Toggle the linear region selector on the plot.
Args:
toggled(bool): If True, enable the linear region selector.
"""
if toggled:
return self._hook_roi()
return self._unhook_roi()
@Slot(tuple)
def select_roi(self, region: tuple[float, float]):
"""Set the fit region of the plot widget. At the moment only a single region is supported.
To remove the roi region again, use toggle_roi_region
Args:
region(tuple[float, float]): The fit region.
"""
if self.roi_region == (None, None):
self.toggle_roi(True)
try:
self.roi_select.linear_region_selector.setRegion(region)
except Exception as e:
logger.error(f"Error setting region {tuple}; Exception raised: {e}")
raise ValueError(f"Error setting region {tuple}; Exception raised: {e}") from e
def _hook_roi(self):
"""Hook the linear region selector to the plot."""
color = self._accent_colors.default
color.setAlpha(int(0.2 * 255))
hover_color = self._accent_colors.default
hover_color.setAlpha(int(0.35 * 255))
if self.roi_select is None:
self.roi_select = LinearRegionWrapper(
self.plot_item, color=color, hover_color=hover_color, parent=self
)
self.roi_select.add_region_selector()
self.roi_select.region_changed.connect(self.roi_changed)
self.roi_select.region_changed.connect(self.set_roi_region)
self.request_dap_refresh.connect(self.refresh_dap)
self._emit_roi_region()
self.roi_active.emit(True)
def _unhook_roi(self):
"""Unhook the linear region selector from the plot."""
if self.roi_select is not None:
self.roi_select.region_changed.disconnect(self.roi_changed)
self.roi_select.region_changed.disconnect(self.set_roi_region)
self.request_dap_refresh.disconnect(self.refresh_dap)
self.roi_active.emit(False)
self.roi_region = None
self.refresh_dap()
self.roi_select.cleanup()
self.roi_select.deleteLater()
self.roi_select = None
def apply_config(self, config: dict | SubplotConfig, replot_last_scan: bool = False):
"""
Apply the configuration to the 1D waveform widget.
@@ -139,7 +212,7 @@ class BECWaveform(BECPlotBase):
try:
config = Waveform1DConfig(**config)
except ValidationError as e:
print(f"Validation error when applying config to BECWaveform1D: {e}")
logger.error(f"Validation error when applying config to BECWaveform1D: {e}")
return
self.config = config
@@ -149,7 +222,7 @@ class BECWaveform(BECPlotBase):
# Reset curves
self._curves_data = defaultdict(dict)
self._curves = self.plot_item.curves
for curve_id, curve_config in self.config.curves.items():
for curve_config in self.config.curves.values():
self.add_curve_by_config(curve_config)
if replot_last_scan:
self.scan_history(scan_index=-1)
@@ -168,6 +241,48 @@ class BECWaveform(BECPlotBase):
for curve in self.curves:
curve.config.parent_id = new_gui_id
###################################
# Fit Range Properties
###################################
@property
def roi_region(self) -> tuple[float, float] | None:
"""
Get the fit region of the plot widget.
Returns:
tuple: The fit region.
"""
if self._roi_region is not None:
return self._roi_region
return None, None
@roi_region.setter
def roi_region(self, value: tuple[float, float] | None):
"""Set the fit region of the plot widget.
Args:
value(tuple[float, float]|None): The fit region.
"""
self._roi_region = value
if value is not None:
self.request_dap_refresh.emit()
@Slot(tuple)
def set_roi_region(self, region: tuple[float, float]):
"""
Set the fit region of the plot widget.
Args:
region(tuple[float, float]): The fit region.
"""
self.roi_region = region
def _emit_roi_region(self):
"""Emit the current ROI from selector the plot widget."""
if self.roi_select is not None:
self.set_roi_region(self.roi_select.linear_region_selector.getRegion())
###################################
# Waveform Properties
###################################
@@ -230,7 +345,7 @@ class BECWaveform(BECPlotBase):
Returns:
CurveConfig|dict: Configuration of the curve.
"""
for source, curves in self._curves_data.items():
for curves in self._curves_data.values():
if curve_id in curves:
if dict_output:
return curves[curve_id].config.model_dump()
@@ -250,13 +365,27 @@ class BECWaveform(BECPlotBase):
if isinstance(identifier, int):
return self.plot_item.curves[identifier]
elif isinstance(identifier, str):
for source_type, curves in self._curves_data.items():
for curves in self._curves_data.values():
if identifier in curves:
return curves[identifier]
raise ValueError(f"Curve with ID '{identifier}' not found.")
else:
raise ValueError("Identifier must be either an integer (index) or a string (curve_id).")
def enable_scatter(self, enable: bool):
"""
Enable/Disable scatter plot on all curves.
Args:
enable(bool): If True, enable scatter markers; if False, disable them.
"""
for curve in self.curves:
if isinstance(curve, BECCurve):
if enable:
curve.set_symbol("o") # You can choose any symbol you like
else:
curve.set_symbol(None)
def plot(
self,
arg1: list | np.ndarray | str | None = None,
@@ -354,6 +483,10 @@ class BECWaveform(BECPlotBase):
- Custom signal name of device from BEC.
x_entry(str): Entry of the x signal.
"""
if not x_name:
# this can happen, if executed by a signal from a widget
return
curve_configs = self.config.curves
curve_ids = list(curve_configs.keys())
curve_configs = list(curve_configs.values())
@@ -401,6 +534,7 @@ class BECWaveform(BECPlotBase):
@Slot()
def auto_range(self):
"""Manually set auto range of the plotitem"""
self.plot_item.autoRange()
def set_auto_range(self, enabled: bool, axis: str = "xy"):
@@ -439,7 +573,6 @@ class BECWaveform(BECPlotBase):
Returns:
BECCurve: The curve object.
"""
curve_source = curve_source
curve_id = label or f"Curve {len(self.plot_item.curves) + 1}"
curve_exits = self._check_curve_id(curve_id, self._curves_data)
@@ -518,6 +651,13 @@ class BECWaveform(BECPlotBase):
x_name = mode if mode is not None else "best_effort"
self.x_axis_mode["name"] = x_name
if not x_name or not y_name:
# can happen if executed from a signal from a widget ;
# the code above has to be executed to set some other
# variables, but it cannot continue if both names are
# not set properly -> exit here
return
# 3. Check - Get entry if not provided and validate
x_entry, y_entry, z_entry = self._validate_signal_entries(
x_name, y_name, z_name, x_entry, y_entry, z_entry, validate_bec
@@ -553,7 +693,7 @@ class BECWaveform(BECPlotBase):
format="HEX",
)[len(self.plot_item.curves)]
)
print(f"Color: {color}")
logger.info(f"Color: {color}")
# Create curve by config
curve_config = CurveConfig(
@@ -613,7 +753,7 @@ class BECWaveform(BECPlotBase):
if self.x_axis_mode["readout_priority"] == "async":
raise ValueError(
f"Async signals cannot be fitted at the moment. Please switch to 'monitored' or 'baseline' signals."
"Async signals cannot be fitted at the moment. Please switch to 'monitored' or 'baseline' signals."
)
if validate_bec is True:
@@ -887,7 +1027,7 @@ class BECWaveform(BECPlotBase):
Args:
curve_id(str): ID of the curve to be removed.
"""
for source, curves in self._curves_data.items():
for curves in self._curves_data.values():
if curve_id in curves:
curve = curves.pop(curve_id)
self.plot_item.removeItem(curve)
@@ -910,7 +1050,7 @@ class BECWaveform(BECPlotBase):
self.plot_item.removeItem(curve)
del self.config.curves[curve_id]
# Remove from self.curve_data
for source, curves in self._curves_data.items():
for curves in self._curves_data.values():
if curve_id in curves:
del curves[curve_id]
break
@@ -940,7 +1080,7 @@ class BECWaveform(BECPlotBase):
if self._curves_data["DAP"]:
self.setup_dap(self.old_scan_id, self.scan_id)
if self._curves_data["async"]:
for curve_id, curve in self._curves_data["async"].items():
for curve in self._curves_data["async"].values():
self.setup_async(
name=curve.config.signals.y.name, entry=curve.config.signals.y.entry
)
@@ -1055,13 +1195,14 @@ class BECWaveform(BECPlotBase):
y_entry = curve.config.signals.y.entry
model_name = curve.config.signals.dap
model = getattr(self.dap, model_name)
x_min, x_max = self.roi_region
msg = messages.DAPRequestMessage(
dap_cls="LmfitService1D",
dap_type="on_demand",
config={
"args": [self.scan_id, x_name, x_entry, y_name, y_entry],
"kwargs": {},
"kwargs": {"x_min": x_min, "x_max": x_max},
"class_args": model._plugin_info["class_args"],
"class_kwargs": model._plugin_info["class_kwargs"],
},
@@ -1071,7 +1212,9 @@ class BECWaveform(BECPlotBase):
@Slot(dict, dict)
def update_dap(self, msg, metadata):
self.msg = msg
"""Callback for DAP response message."""
# pylint: disable=unused-variable
scan_id, x_name, x_entry, y_name, y_entry = msg["dap_request"].content["config"]["args"]
model = msg["dap_request"].content["config"]["class_kwargs"]["model"]
@@ -1085,8 +1228,9 @@ class BECWaveform(BECPlotBase):
curve.setData(x, y)
curve.dap_params = msg["data"][1]["fit_parameters"]
curve.dap_summary = msg["data"][1]["fit_summary"]
self.dap_params_update.emit(curve.dap_params)
self.dap_summary_update.emit(curve.dap_summary)
metadata.update({"curve_id": curve_id_request})
self.dap_params_update.emit(curve.dap_params, metadata)
self.dap_summary_update.emit(curve.dap_summary, metadata)
break
@Slot(dict, dict)
@@ -1099,7 +1243,7 @@ class BECWaveform(BECPlotBase):
metadata(dict): Metadata of the message.
"""
instruction = metadata.get("async_update")
for curve_id, curve in self._curves_data["async"].items():
for curve in self._curves_data["async"].values():
y_name = curve.config.signals.y.name
y_entry = curve.config.signals.y.entry
x_name = self._x_axis_mode["name"]
@@ -1158,7 +1302,11 @@ class BECWaveform(BECPlotBase):
Update the scan curves with the data from the scan segment.
"""
try:
data = self.scan_item.data
data = (
self.scan_item.live_data
if hasattr(self.scan_item, "live_data") # backward compatibility
else self.scan_item.data
)
except AttributeError:
return
@@ -1211,8 +1359,14 @@ class BECWaveform(BECPlotBase):
list|np.ndarray|None: X data for the curve.
"""
x_data = None
live_data = (
self.scan_item.live_data
if hasattr(self.scan_item, "live_data")
else self.scan_item.data
)
if self._x_axis_mode["name"] == "timestamp":
timestamps = self.scan_item.data[y_name][y_entry].timestamps
timestamps = live_data[y_name][y_entry].timestamps
x_data = timestamps
return x_data
@@ -1223,7 +1377,7 @@ class BECWaveform(BECPlotBase):
if self._x_axis_mode["name"] is None or self._x_axis_mode["name"] == "best_effort":
if len(self._curves_data["async"]) > 0:
x_data = None
self._x_axis_mode["label_suffix"] = f" [auto: index]"
self._x_axis_mode["label_suffix"] = " [auto: index]"
current_label = "" if self.config.axis.x_label is None else self.config.axis.x_label
self.plot_item.setLabel(
"bottom", f"{current_label}{self._x_axis_mode['label_suffix']}"
@@ -1232,7 +1386,7 @@ class BECWaveform(BECPlotBase):
else:
x_name = self.scan_item.status_message.info["scan_report_devices"][0]
x_entry = self.entry_validator.validate_signal(x_name, None)
x_data = self.scan_item.data[x_name][x_entry].val
x_data = live_data[x_name][x_entry].val
self._x_axis_mode["label_suffix"] = f" [auto: {x_name}-{x_entry}]"
current_label = "" if self.config.axis.x_label is None else self.config.axis.x_label
self.plot_item.setLabel(
@@ -1243,7 +1397,7 @@ class BECWaveform(BECPlotBase):
x_name = curve.config.signals.x.name
x_entry = curve.config.signals.x.entry
try:
x_data = self.scan_item.data[x_name][x_entry].val
x_data = live_data[x_name][x_entry].val
except TypeError:
x_data = []
return x_data
@@ -1290,7 +1444,7 @@ class BECWaveform(BECPlotBase):
try:
self.scan_id = self.queue.scan_storage.storage[scan_index].scan_id
except IndexError:
print(f"Scan index {scan_index} out of range.")
logger.error(f"Scan index {scan_index} out of range.")
return
elif scan_id is not None:
self.scan_id = scan_id
@@ -1300,7 +1454,7 @@ class BECWaveform(BECPlotBase):
self.scan_signal_update.emit()
self.async_signal_update.emit()
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict: # | pd.DataFrame:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
@@ -1316,7 +1470,7 @@ class BECWaveform(BECPlotBase):
except ImportError:
pd = None
if output == "pandas":
print(
logger.warning(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
@@ -1347,13 +1501,21 @@ class BECWaveform(BECPlotBase):
"""
MatplotlibExporter(self.plot_item).export()
def clear_all(self):
def clear_source(self, source: Literal["DAP", "async", "scan_segment", "custom"]):
"""Clear speicific source from self._curves_data.
Args:
source (Literal["DAP", "async", "scan_segment", "custom"]): Source to be cleared.
"""
curves_data = self._curves_data
sources = list(curves_data.keys())
curve_ids_to_remove = list(curves_data[source].keys())
for curve_id in curve_ids_to_remove:
self.remove_curve(curve_id)
def clear_all(self):
sources = list(self._curves_data.keys())
for source in sources:
curve_ids_to_remove = list(curves_data[source].keys())
for curve_id in curve_ids_to_remove:
self.remove_curve(curve_id)
self.clear_source(source)
def cleanup(self):
"""Cleanup the widget connection from BECDispatcher."""
@@ -1361,7 +1523,7 @@ class BECWaveform(BECPlotBase):
self.bec_dispatcher.disconnect_slot(
self.update_dap, MessageEndpoints.dap_response(self.scan_id)
)
for curve_id, curve in self._curves_data["async"].items():
for curve_id in self._curves_data["async"]:
self.bec_dispatcher.disconnect_slot(
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, curve_id),

View File

@@ -4,8 +4,8 @@ from typing import TYPE_CHECKING, Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
@@ -13,6 +13,8 @@ from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.waveform import BECWaveform1D
logger = bec_logger.logger
class SignalData(BaseModel):
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
@@ -40,7 +42,7 @@ class CurveConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[str | tuple] = Field(None, description="The color of the curve.")
symbol: Optional[str] = Field("o", description="The symbol of the curve.")
symbol: Optional[str | None] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str | tuple] = Field(
None, description="The color of the symbol of the curve."
)
@@ -177,7 +179,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
if key in method_map:
method_map[key](value)
else:
print(f"Warning: '{key}' is not a recognized property.")
logger.warning(f"Warning: '{key}' is not a recognized property.")
def set_color(self, color: str, symbol_color: Optional[str] = None):
"""
@@ -199,7 +201,8 @@ class BECCurve(BECConnector, pg.PlotDataItem):
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.apply_config()
self.setSymbol(symbol)
self.updateItems()
def set_symbol_color(self, symbol_color: str):
"""

View File

@@ -4,7 +4,8 @@ import sys
from typing import Literal, Optional
import pyqtgraph as pg
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
@@ -13,8 +14,10 @@ from bec_widgets.qt_utils.toolbar import (
MaterialIconAction,
ModularToolBar,
SeparatorAction,
WidgetAction,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings
@@ -40,6 +43,7 @@ class BECImageWidget(BECWidget, QWidget):
"set_rotation",
"set_log",
"set_grid",
"enable_fps_monitor",
"lock_aspect_ratio",
]
@@ -62,11 +66,18 @@ class BECImageWidget(BECWidget, QWidget):
self.layout.setContentsMargins(0, 0, 0, 0)
self.fig = BECFigure()
self.dim_combo_box = QComboBox()
self.dim_combo_box.addItems(["1d", "2d"])
self.toolbar = ModularToolBar(
actions={
"monitor": DeviceSelectionAction(
"Monitor:", DeviceComboBox(device_filter="Device")
"Monitor:",
DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
),
),
"monitor_type": WidgetAction(widget=self.dim_combo_box),
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Device"),
"separator_0": SeparatorAction(),
"save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
@@ -104,6 +115,9 @@ class BECImageWidget(BECWidget, QWidget):
icon_name="reset_settings", tooltip="Reset Image Settings"
),
"separator_3": SeparatorAction(),
"fps_monitor": MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True
),
"axis_settings": MaterialIconAction(
icon_name="settings", tooltip="Open Configuration Dialog"
),
@@ -150,6 +164,7 @@ class BECImageWidget(BECWidget, QWidget):
self.toolbar.widgets["reset"].action.triggered.connect(self.reset_settings)
# sepatator
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor)
###################################
# Dialog Windows
@@ -158,7 +173,8 @@ class BECImageWidget(BECWidget, QWidget):
def _connect_action(self):
monitor_combo = self.toolbar.widgets["monitor"].device_combobox
monitor_name = monitor_combo.currentText()
self.image(monitor_name)
monitor_type = self.toolbar.widgets["monitor_type"].widget.currentText()
self.image(monitor=monitor_name, monitor_type=monitor_type)
monitor_combo.setStyleSheet("QComboBox { background-color: " "; }")
def show_axis_settings(self):
@@ -177,6 +193,7 @@ class BECImageWidget(BECWidget, QWidget):
def image(
self,
monitor: str,
monitor_type: Optional[Literal["1d", "2d"]] = "2d",
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
@@ -190,8 +207,14 @@ class BECImageWidget(BECWidget, QWidget):
self.toolbar.widgets["monitor"].device_combobox.setStyleSheet(
"QComboBox {{ background-color: " "; }}"
)
if self.toolbar.widgets["monitor_type"].widget.currentText() != monitor_type:
self.toolbar.widgets["monitor_type"].widget.setCurrentText(monitor_type)
self.toolbar.widgets["monitor_type"].widget.setStyleSheet(
"QComboBox {{ background-color: " "; }}"
)
return self._image.image(
monitor=monitor,
monitor_type=monitor_type,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
@@ -450,6 +473,18 @@ class BECImageWidget(BECWidget, QWidget):
self.toolbar.widgets["rectangle_mode"].action.setChecked(False)
self._image.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
@SafeSlot()
def enable_fps_monitor(self, enabled: bool):
"""
Enable the FPS monitor of the plot widget.
Args:
enabled(bool): If True, enable the FPS monitor.
"""
self._image.enable_fps_monitor(enabled)
if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled:
self.toolbar.widgets["fps_monitor"].action.setChecked(enabled)
def export(self):
"""
Show the export dialog for the plot widget.
@@ -469,6 +504,7 @@ def main(): # pragma: no cover
app = QApplication(sys.argv)
widget = BECImageWidget()
widget.image("waveform", "1d")
widget.show()
sys.exit(app.exec_())

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.lmfit_dialog.lmfit_dialog import LMFitDialog
DOM_XML = """
<ui language='c++'>
<widget class='LMFitDialog' name='lm_fit_dialog'>
</widget>
</ui>
"""
class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = LMFitDialog(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(LMFitDialog.ICON_NAME)
def includeFile(self):
return "lm_fit_dialog"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "LMFitDialog"
def toolTip(self):
return "LMFitDialog"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,329 @@
import os
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
logger = bec_logger.logger
class LMFitDialog(BECWidget, QWidget):
"""Dialog for displaying the fit summary and params for LMFit DAP processes"""
ICON_NAME = "monitoring"
# Signal to emit the currently selected fit curve_id
selected_fit = Signal(str)
# Signal to emit a move action in form of a tuple (param_name, value)
move_action = Signal(tuple)
def __init__(
self,
parent=None,
client=None,
config=None,
target_widget=None,
gui_id: str | None = None,
ui_file="lmfit_dialog_vertical.ui",
):
"""
Initialises the LMFitDialog widget.
Args:
parent (QWidget): The parent widget.
client: BEC client object.
config: Configuration of the widget.
target_widget: The widget that the settings will be taken from and applied to.
gui_id (str): GUI ID.
ui_file (str): The UI file to be loaded.
"""
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self._ui_file = ui_file
self.target_widget = target_widget
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, self._ui_file))
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
self.summary_data = {}
self._fit_curve_id = None
self._deci_precision = 3
self._always_show_latest = False
self.ui.curve_list.currentItemChanged.connect(self.display_fit_details)
self.layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.layout)
self._active_actions = []
self._enable_actions = True
self._move_buttons = []
self._accent_colors = get_accent_colors()
self.action_buttons = {}
@property
def enable_actions(self) -> bool:
"""Property to enable the move to buttons."""
return self._enable_actions
@enable_actions.setter
def enable_actions(self, enable: bool):
self._enable_actions = enable
for button in self.action_buttons.values():
button.setEnabled(enable)
@Property(list)
def active_action_list(self) -> list[str]:
"""Property to list the names of the fit parameters for which actions should be enabled."""
return self._active_actions
@active_action_list.setter
def active_action_list(self, actions: list[str]):
self._active_actions = actions
# This slot needed?
@Slot(bool)
def set_actions_enabled(self, enable: bool) -> bool:
"""Slot to enable the move to buttons.
Args:
enable (bool): Whether to enable the action buttons.
"""
self.enable_actions = enable
@Property(bool)
def always_show_latest(self):
"""Property to indicate if always the latest DAP update is displayed."""
return self._always_show_latest
@always_show_latest.setter
def always_show_latest(self, show: bool):
self._always_show_latest = show
@Property(bool)
def hide_curve_selection(self):
"""Property for showing the curve selection."""
return not self.ui.group_curve_selection.isVisible()
@hide_curve_selection.setter
def hide_curve_selection(self, show: bool):
"""Setter for showing the curve selection.
Args:
show (bool): Whether to show the curve selection.
"""
self.ui.group_curve_selection.setVisible(not show)
@Property(bool)
def hide_summary(self) -> bool:
"""Property for showing the summary."""
return not self.ui.group_summary.isVisible()
@hide_summary.setter
def hide_summary(self, show: bool):
"""Setter for showing the summary.
Args:
show (bool): Whether to show the summary.
"""
self.ui.group_summary.setVisible(not show)
@Property(bool)
def hide_parameters(self) -> bool:
"""Property for showing the parameters."""
return not self.ui.group_parameters.isVisible()
@hide_parameters.setter
def hide_parameters(self, show: bool):
"""Setter for showing the parameters.
Args:
show (bool): Whether to show the parameters.
"""
self.ui.group_parameters.setVisible(not show)
@property
def fit_curve_id(self) -> str:
"""Property for the currently displayed fit curve_id."""
return self._fit_curve_id
@fit_curve_id.setter
def fit_curve_id(self, curve_id: str):
"""Setter for the currently displayed fit curve_id.
Args:
fit_curve_id (str): The curve_id of the fit curve to be displayed.
"""
self._fit_curve_id = curve_id
self.selected_fit.emit(curve_id)
@Slot(str)
def remove_dap_data(self, curve_id: str):
"""Remove the DAP data for the given curve_id.
Args:
curve_id (str): The curve_id of the DAP data to be removed.
"""
self.summary_data.pop(curve_id, None)
self.refresh_curve_list()
@Slot(str)
def select_curve(self, curve_id: str):
"""Select active curve_id in the curve list.
Args:
curve_id (str): curve_id to be selected.
"""
self.fit_curve_id = curve_id
@Slot(dict, dict)
def update_summary_tree(self, data: dict, metadata: dict):
"""Update the summary tree with the given data.
Args:
data (dict): Data for the DAP Summary.
metadata (dict): Metadata of the fit curve.
"""
curve_id = metadata.get("curve_id", "")
self.summary_data.update({curve_id: data})
self.refresh_curve_list()
if self.fit_curve_id is None or self.always_show_latest is True:
self.fit_curve_id = curve_id
if curve_id != self.fit_curve_id:
return
if data is None:
return
self.ui.summary_tree.clear()
chi_squared = data.get("chisqr", 0.0)
if isinstance(chi_squared, float) or isinstance(chi_squared, int):
chi_squared = f"{chi_squared:.{self._deci_precision}f}"
else:
chi_squared = "None"
reduced_chi_squared = data.get("redchi", 0.0)
if isinstance(reduced_chi_squared, float) or isinstance(reduced_chi_squared, int):
reduced_chi_squared = f"{reduced_chi_squared:.{self._deci_precision}f}"
else:
reduced_chi_squared = "None"
r_squared = data.get("rsquared", 0.0)
if isinstance(r_squared, float) or isinstance(r_squared, int):
r_squared = f"{r_squared:.{self._deci_precision}f}"
else:
r_squared = "None"
properties = [
("Model", data.get("model", "")),
("Method", data.get("method", "")),
("Chi-Squared", chi_squared),
("Reduced Chi-Squared", reduced_chi_squared),
("R-Squared", r_squared),
("Message", data.get("message", "")),
]
for prop, val in properties:
QTreeWidgetItem(self.ui.summary_tree, [prop, val])
self.update_param_tree(data.get("params", []))
def _update_summary_data(self, curve_id: str, data: dict):
"""Update the summary data with the given data.
Args:
curve_id (str): The curve_id of the fit curve.
data (dict): The data to be updated.
"""
self.summary_data.update({curve_id: data})
if self.fit_curve_id is not None:
return
self.fit_curve_id = curve_id
def update_param_tree(self, params):
"""Update the parameter tree with the given parameters.
Args:
params (list): List of LMFit parameters for the fit curve.
"""
self._move_buttons = []
self.ui.param_tree.clear()
for param in params:
param_name = param[0]
param_value = param[1]
if isinstance(param_value, float) or isinstance(param_value, int):
param_value = f"{param_value:.{self._deci_precision}f}"
else:
param_value = "None"
param_std = param[7]
if isinstance(param_std, float) or isinstance(param_std, int):
param_std = f"{param_std:.{self._deci_precision}f}"
else:
param_std = "None"
tree_item = QTreeWidgetItem(self.ui.param_tree, [param_name, param_value, param_std])
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test
# Create a push button to move the motor to a specific position
widget = QWidget()
button = QPushButton(f"Move to {param_name}")
button.clicked.connect(self._create_move_action(param_name, param[1]))
if self.enable_actions is True:
button.setEnabled(True)
else:
button.setEnabled(False)
button.setStyleSheet(
f"""
QPushButton:enabled {{ background-color: {self._accent_colors.success.name()};color: white; }}
QPushButton:disabled {{ background-color: grey;color: white; }}
"""
)
self.action_buttons[param_name] = button
layout = QVBoxLayout()
layout.addWidget(self.action_buttons[param_name])
layout.setContentsMargins(0, 0, 0, 0)
widget.setLayout(layout)
self.ui.param_tree.setItemWidget(tree_item, 3, widget)
def _create_move_action(self, param_name: str, param_value: float) -> callable:
"""Create a move action for the given parameter name and value.
Args:
param_name (str): The name of the parameter.
param_value (float): The value of the parameter.
Returns:
callable: The move action with the given parameter name and value.
"""
def move_action():
self.move_action.emit((param_name, param_value))
return move_action
def populate_curve_list(self):
"""Populate the curve list with the available fit curves."""
for curve_name in self.summary_data:
self.ui.curve_list.addItem(curve_name)
def refresh_curve_list(self):
"""Refresh the curve list with the updated data."""
self.ui.curve_list.clear()
self.populate_curve_list()
def display_fit_details(self, current):
"""Callback for displaying the fit details of the selected curve.
Args:
current: The current item in the curve list.
"""
if current:
curve_name = current.text()
self.fit_curve_id = curve_name
data = self.summary_data[curve_name]
if data is None:
return
self.update_summary_tree(data, {"curve_id": curve_name})
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
dialog = LMFitDialog()
dialog.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>655</width>
<height>520</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QSplitter" name="splitter_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::Shape::VLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<property name="lineWidth">
<number>1</number>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="opaqueResize">
<bool>true</bool>
</property>
<property name="childrenCollapsible">
<bool>true</bool>
</property>
<widget class="QGroupBox" name="group_curve_selection">
<property name="title">
<string>Select Curve</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QListWidget" name="curve_list"/>
</item>
</layout>
</widget>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>2</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<widget class="QGroupBox" name="group_summary">
<property name="title">
<string>Fit Summary</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QTreeWidget" name="summary_tree">
<property name="uniformRowHeights">
<bool>false</bool>
</property>
<column>
<property name="text">
<string>Property</string>
</property>
</column>
<column>
<property name="text">
<string>Value</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
<widget class="QGroupBox" name="group_parameters">
<property name="title">
<string>Parameter Details</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTreeWidget" name="param_tree">
<column>
<property name="text">
<string>Parameter</string>
</property>
</column>
<column>
<property name="text">
<string>Value</string>
</property>
</column>
<column>
<property name="text">
<string>Std</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>303</width>
<height>457</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4" stretch="1,2,2">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QGroupBox" name="group_curve_selection">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Select Curve</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="3">
<item>
<widget class="QListWidget" name="curve_list">
<property name="sizePolicy">
<sizepolicy hsizetype="Ignored" vsizetype="Ignored">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="group_summary">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Fit Summary</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QTreeWidget" name="summary_tree">
<property name="sizePolicy">
<sizepolicy hsizetype="Ignored" vsizetype="Ignored">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="uniformRowHeights">
<bool>false</bool>
</property>
<attribute name="headerDefaultSectionSize">
<number>80</number>
</attribute>
<column>
<property name="text">
<string>Property</string>
</property>
</column>
<column>
<property name="text">
<string>Value</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="group_parameters">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Parameter Details</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTreeWidget" name="param_tree">
<property name="sizePolicy">
<sizepolicy hsizetype="Ignored" vsizetype="Ignored">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="columnCount">
<number>4</number>
</property>
<attribute name="headerDefaultSectionSize">
<number>70</number>
</attribute>
<column>
<property name="text">
<string>Parameter</string>
</property>
</column>
<column>
<property name="text">
<string>Value</string>
</property>
</column>
<column>
<property name="text">
<string>Std</string>
</property>
</column>
<column>
<property name="text">
<string>Action</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.lmfit_dialog.lm_fit_dialog_plugin import LMFitDialogPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(LMFitDialogPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -27,7 +27,7 @@ class MotorMapSettings(SettingWidget):
background_intensity = int((config["background_value"] / 255) * 100)
WidgetIO.set_value(self.ui.background_value, background_intensity)
color = config["color"]
self.ui.color.setColor(color)
self.ui.color.set_color(color)
@Slot()
def accept_changes(self):

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
import sys
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import DeviceSelectionAction, MaterialIconAction, ModularToolBar
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.motor_map.motor_map import MotorMapConfig
@@ -50,10 +52,10 @@ class BECMotorMapWidget(BECWidget, QWidget):
self.toolbar = ModularToolBar(
actions={
"motor_x": DeviceSelectionAction(
"Motor X:", DeviceComboBox(device_filter="Positioner")
"Motor X:", DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
),
"motor_y": DeviceSelectionAction(
"Motor Y:", DeviceComboBox(device_filter="Positioner")
"Motor Y:", DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
),
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Motors"),
"history": MaterialIconAction(icon_name="history", tooltip="Reset Trace History"),

View File

@@ -1,67 +1,285 @@
from qtpy.QtCore import Qt, Slot
from qtpy.QtGui import QPainter, QPen
import numpy as np
from qtpy.QtCore import Property, QSize, Qt, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(QWidget):
class PositionIndicator(BECWidget, QWidget):
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None):
super().__init__(parent)
self.position = 0.5
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.position = 50
self.min_value = 0
self.max_value = 100
self.scaling_factor = 0.5
self.setMinimumHeight(10)
self.is_vertical = False
self._current_indicator_position = 0
self._draw_position = 0
self._rounded_corners = 10
self._indicator_width = 2
self._indicator_color = get_accent_colors().success
self._background_color = get_theme_palette().mid().color()
self._use_color_palette = True
def set_range(self, min_value, max_value):
def set_range(self, min_value: float, max_value: float):
"""
Set the range of the position indicator
Args:
min_value(float): Minimum value of the range
max_value(float): Maximum value of the range
"""
self.minimum = min_value
self.maximum = max_value
@Property(float)
def minimum(self):
"""
Property to get the minimum value of the position indicator
"""
return self.min_value
@minimum.setter
def minimum(self, min_value: float):
"""
Setter for the minimum property
Args:
min_value: The minimum value of the position indicator
"""
self.min_value = min_value
self.max_value = max_value
self.update()
@Property(float)
def maximum(self):
"""
Property to get the maximum value of the position indicator
"""
return self.max_value
@maximum.setter
def maximum(self, max_value: float):
"""
Setter for the maximum property
Args:
max_value: The maximum value of the position indicator
"""
self.max_value = max_value
self.update()
@Property(bool)
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
return self.is_vertical
@vertical.setter
def vertical(self, is_vertical: bool):
"""
Setter for the vertical property
Args:
is_vertical: True if the indicator should be vertical, False if horizontal
"""
self.is_vertical = is_vertical
self.update()
@Property(float)
def value(self):
"""
Property to get the current value of the position indicator
"""
return self.position
@value.setter
def value(self, position: float):
"""
Setter for the value property
Args:
position: The new position of the indicator
"""
self.set_value(position)
@Property(int)
def indicator_width(self):
"""
Property to get the width of the indicator
"""
return self._indicator_width
@indicator_width.setter
def indicator_width(self, width: int):
"""
Setter for the indicator width property
Args:
width: The new width of the indicator
"""
self._indicator_width = width
self.update()
@Property(int)
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
return self._rounded_corners
@rounded_corners.setter
def rounded_corners(self, value: int):
"""
Setter for the rounded corners property
Args:
value: The new value for the rounded corners
"""
self._rounded_corners = value
self.update()
@Property(QColor)
def indicator_color(self):
"""
Property to get the color of the indicator
"""
return self._indicator_color
@indicator_color.setter
def indicator_color(self, color: QColor):
"""
Setter for the indicator color property
Args:
color: The new color for the indicator
"""
self._indicator_color = color
self.update()
@Property(QColor)
def background_color(self):
"""
Property to get the background color of the position indicator
"""
return self._background_color
@background_color.setter
def background_color(self, color: QColor):
"""
Setter for the background color property
Args:
color: The new background color
"""
self._background_color = color
self.update()
@Property(bool)
def use_color_palette(self):
"""
Property to determine if the indicator should use the color palette or the custom color.
"""
return self._use_color_palette
@use_color_palette.setter
def use_color_palette(self, use_palette: bool):
"""
Setter for the use color palette property
Args:
use_palette: True if the indicator should use the color palette, False if custom color
"""
self._use_color_palette = use_palette
self.update()
# @Property(float)
@Slot(int)
@Slot(float)
def on_position_update(self, position: float):
def set_value(self, position: float):
self.position = position
self.update()
def _get_indicator_color(self):
if self._use_color_palette:
return get_accent_colors().success
return self._indicator_color
def _get_background_brush(self):
if self._use_color_palette:
return get_theme_palette().mid()
return QBrush(self._background_color)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
width = self.width()
height = self.height()
# Draw horizontal line
painter.setPen(Qt.black)
painter.drawLine(0, height // 2, width, height // 2)
# Set up the brush for the background
painter.setBrush(self._get_background_brush())
# Draw shorter vertical line at the current position
x_pos = int(self.position * width)
painter.setPen(QPen(Qt.red, 2))
short_line_height = int(height * self.scaling_factor)
painter.drawLine(
x_pos,
(height // 2) - (short_line_height // 2),
x_pos,
(height // 2) + (short_line_height // 2),
# Create a QPainterPath with a rounded rectangle for clipping
path = QPainterPath()
path.addRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners)
# Set clipping to the rounded rectangle
painter.setClipPath(path)
# Draw the rounded rectangle background first
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners)
# get the position scaled to the defined min and max values
self._current_indicator_position = position = np.interp(
self.position, [self.min_value, self.max_value], [0, 100]
)
# Draw thicker vertical lines at the ends
end_line_pen = QPen(Qt.blue, 5)
painter.setPen(end_line_pen)
painter.drawLine(0, 0, 0, height)
painter.drawLine(width - 1, 0, width - 1, height)
if self.is_vertical:
# If vertical, rotate the coordinate system by -90 degrees
painter.translate(width // 2, height // 2) # Move origin to center
painter.rotate(-90) # Rotate by -90 degrees for vertical drawing
painter.translate(-height // 2, -width // 2) # Restore the origin for drawing
# Switch width and height for the vertical orientation
width, height = height, width
# Draw the moving vertical indicator, respecting the clip path
self._draw_position = x_pos = round(
position * width / 100
) # Position for the vertical line
indicator_pen = QPen(self._get_indicator_color(), self._indicator_width)
painter.setPen(indicator_pen)
painter.drawLine(x_pos, 0, x_pos, height)
painter.end()
def minimumSizeHint(self):
# Set the smallest possible size
return QSize(10, 10)
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
from bec_qthemes import setup_theme
from qtpy.QtWidgets import QApplication, QSlider, QVBoxLayout
app = QApplication([])
setup_theme("dark")
# Create position indicator and slider
position_indicator = PositionIndicator()
# position_indicator.set_range(0, 1)
slider = QSlider(Qt.Horizontal)
slider.valueChanged.connect(lambda value: position_indicator.on_position_update(value / 100))
slider.valueChanged.connect(lambda value: position_indicator.set_value(value))
position_indicator.is_vertical = False
# position_indicator.set_value(100)
layout = QVBoxLayout()
layout.addWidget(position_indicator)
layout.addWidget(slider)

View File

@@ -14,9 +14,11 @@ from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout, QWidget
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 set_theme
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
logger = bec_logger.logger
@@ -24,7 +26,7 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBox(BECWidget, QWidget):
class PositionerBox(BECWidget, CompactPopupWidget):
"""Simple Widget to control a positioner in box form"""
ui_file = "positioner_box.ui"
@@ -33,6 +35,8 @@ class PositionerBox(BECWidget, QWidget):
ICON_NAME = "switch_right"
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)
def __init__(self, parent=None, device: Positioner = None, **kwargs):
"""Initialize the PositionerBox widget.
@@ -42,10 +46,11 @@ class PositionerBox(BECWidget, QWidget):
device (Positioner): The device to control.
"""
super().__init__(**kwargs)
QWidget.__init__(self, parent=parent)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self.get_bec_shortcuts()
self._device = ""
self._limits = None
self._dialog = None
self.init_ui()
@@ -60,8 +65,7 @@ class PositionerBox(BECWidget, QWidget):
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, self.ui_file))
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
@@ -72,6 +76,10 @@ class PositionerBox(BECWidget, QWidget):
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
self.ui.stop.clicked.connect(self.on_stop)
self.ui.stop.setToolTip("Stop")
self.ui.stop.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
self.ui.tweak_right.setToolTip("Tweak right")
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
@@ -87,17 +95,20 @@ class PositionerBox(BECWidget, QWidget):
def _open_dialog_selection(self):
"""Open dialog window for positioner selection"""
dialog = QDialog(self)
dialog.setWindowTitle("Positioner Selection")
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(self, client=self.client, device_filter="Positioner")
line_edit.textChanged.connect(self._positioner_changed)
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")
close_button.clicked.connect(dialog.accept)
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
dialog.setLayout(layout)
dialog.exec()
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
def init_device(self):
"""Init the device view and readback"""
@@ -127,8 +138,12 @@ class PositionerBox(BECWidget, QWidget):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
old_device = self._device
self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value)
@Property(bool)
@@ -233,12 +248,15 @@ class PositionerBox(BECWidget, QWidget):
if is_moving:
self.ui.spinner_widget.start()
self.ui.spinner_widget.setToolTip("Device is moving")
self.set_global_state("warning")
else:
self.ui.spinner_widget.stop()
self.ui.spinner_widget.setToolTip("Device is idle")
self.set_global_state("success")
if readback_val is not None:
self.ui.readback.setText(f"{readback_val:.{precision}f}")
self.position_update.emit(readback_val)
if setpoint_val is not None:
self.ui.setpoint.setText(f"{setpoint_val:.{precision}f}")
@@ -247,7 +265,7 @@ class PositionerBox(BECWidget, QWidget):
self.update_limits(limits)
if limits is not None and readback_val is not None and limits[0] != limits[1]:
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
self.ui.position_indicator.on_position_update(pos)
self.ui.position_indicator.set_value(pos)
def update_limits(self, limits: tuple):
"""Update limits
@@ -312,7 +330,7 @@ class PositionerBox(BECWidget, QWidget):
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")

View File

@@ -170,7 +170,20 @@
</layout>
</item>
<item>
<widget class="PositionIndicator" name="position_indicator"/>
<widget class="PositionIndicator" name="position_indicator">
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="indicator_width" stdset="0">
<number>4</number>
</property>
<property name="rounded_corners" stdset="0">
<number>4</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="readback">

View File

@@ -24,11 +24,9 @@ class PositionerControlLine(PositionerBox):
if __name__ == "__main__": # pragma: no cover
import sys
import qdarktheme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("dark")
widget = PositionerControlLine(device="samy")
widget.show()

View File

@@ -6,7 +6,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>785</width>
<width>612</width>
<height>91</height>
</rect>
</property>
@@ -59,16 +59,6 @@
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="PositionIndicator" name="position_indicator">
<property name="minimumSize">
<size>
<width>80</width>
<height>10</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget">
<property name="minimumSize">
@@ -139,14 +129,14 @@
<widget class="QToolButton" name="tweak_left">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
@@ -154,8 +144,8 @@
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="arrowType">
@@ -170,14 +160,14 @@
<widget class="QToolButton" name="tweak_right">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
@@ -185,8 +175,8 @@
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="arrowType">
@@ -194,6 +184,34 @@
</property>
</widget>
</item>
<item>
<widget class="PositionIndicator" name="position_indicator">
<property name="minimumSize">
<size>
<width>15</width>
<height>10</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>15</width>
<height>16777215</height>
</size>
</property>
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="vertical" stdset="0">
<bool>true</bool>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="rounded_corners" stdset="0">
<number>2</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>

View File

@@ -0,0 +1,170 @@
""" Module for a PositionerGroup widget to control a positioner device."""
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 bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
logger = bec_logger.logger
class PositionerGroupBox(QGroupBox):
position_update = Signal(float)
def __init__(self, parent, dev_name):
super().__init__(parent)
self.device_name = dev_name
QVBoxLayout(self)
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
self.widget = PositionerBox(self, dev_name)
self.widget.compact_view = True
self.widget.expand_popup = False
self.layout().addWidget(self.widget)
self.widget.position_update.connect(self._on_position_update)
self.widget.expand.connect(self._on_expand)
self.setTitle(self.device_name)
self.widget.init_device() # force readback
def _on_expand(self, expand):
if expand:
self.setTitle("")
self.setFlat(True)
else:
self.setTitle(self.device_name)
self.setFlat(False)
def _on_position_update(self, pos: float):
self.position_update.emit(pos)
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % pos
def close(self):
self.widget.close()
super().close()
class PositionerGroup(BECWidget, QWidget):
"""Simple Widget to control a positioner in box form"""
ICON_NAME = "grid_view"
USER_ACCESS = ["set_positioners"]
# Signal emitted to inform listeners about a position update of the first positioner
position_update = Signal(float)
# Signal emitted to inform listeners about (positioner, pos) updates
device_position_update = Signal(str, float)
def __init__(self, parent=None, **kwargs):
"""Initialize the widget.
Args:
parent: The parent widget.
"""
super().__init__(**kwargs)
QWidget.__init__(self, parent)
self.get_bec_shortcuts()
QGridLayout(self)
self.layout().setContentsMargins(0, 0, 0, 0)
self._device_widgets = {}
self._grid_ncols = 2
def minimumSizeHint(self):
return QSize(300, 30)
@Slot(str)
def set_positioners(self, device_names: str):
"""Redraw grid with positioners from device_names string
Device names must be separated by space
"""
devs = device_names.split()
for dev_name in devs:
if not self._check_device_is_valid(dev_name):
raise ValueError(f"{dev_name} is not a valid Positioner")
for i, existing_widget in enumerate(self._device_widgets.values()):
self.layout().removeWidget(existing_widget)
existing_widget.position_update.disconnect(self._on_position_update)
if i == 0:
existing_widget.position_update.disconnect(self.position_update)
for i, dev_name in enumerate(devs):
widget = self._device_widgets.get(dev_name)
if widget is None:
widget = PositionerGroupBox(self, dev_name)
self._device_widgets[dev_name] = widget
widget.position_update.connect(self._on_position_update)
if i == 0:
# only emit 'position_update' for the first positioner in grid
widget.position_update.connect(self.position_update)
self.layout().addWidget(widget, i // self._grid_ncols, i % self._grid_ncols)
to_remove = set(self._device_widgets) - set(devs)
for dev_name in to_remove:
self._device_widgets[dev_name].close()
del self._device_widgets[dev_name]
def _check_device_is_valid(self, device: str):
"""Check if the device is a positioner
Args:
device (str): The device name
"""
if device not in self.dev:
logger.info(f"Device {device} not found in the device list")
return False
if not isinstance(self.dev[device], Positioner):
logger.info(f"Device {device} is not a positioner")
return False
return True
def _on_position_update(self, pos: float):
widget = self.sender()
self.device_position_update.emit(widget.title(), pos)
@Property(str)
def devices_list(self):
"""Device names string separated by space"""
return " ".join(self._device_widgets)
@devices_list.setter
def devices_list(self, device_names: str):
"""Set devices list from device names string separated by space"""
devs = device_names.split()
for dev_name in devs:
if not self._check_device_is_valid(dev_name):
return
self.set_positioners(device_names)
@Property(int)
def grid_max_cols(self):
"""Max number of columns for widgets grid"""
return self._grid_ncols
@grid_max_cols.setter
def grid_max_cols(self, ncols: int):
"""Set max number of columns for widgets grid"""
self._grid_ncols = ncols
self.set_positioners(self.devices_list)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
widget = PositionerGroup()
widget.grid_max_cols = 3
widget.set_positioners("samx samy samz")
widget.show()
sys.exit(app.exec_())

View File

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

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