1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-07 09:17:53 +02:00

Compare commits

..

558 Commits

Author SHA1 Message Date
semantic-release
7b9a36403d 0.99.6
Automatically generated by python-semantic-release
2024-08-28 13:42:33 +00:00
09c6c93c39 fix(toolbar): use of native qt separators 2024-08-28 15:33:39 +02:00
c31e9a3aff docs: various bugs fixed 2024-08-28 15:17:31 +02:00
semantic-release
960d84b7fe 0.99.5
Automatically generated by python-semantic-release
2024-08-28 13:09:31 +00:00
e6f204b6aa fix(dock_area): dark button added 2024-08-28 15:06:59 +02:00
02239de0a3 docs(index): index page is centered 2024-08-28 15:06:08 +02:00
semantic-release
0aad9a0988 0.99.4
Automatically generated by python-semantic-release
2024-08-28 13:05:07 +00:00
c5501860e8 fix(theme): apply theme to all pyqtgraph widgets on manual updates 2024-08-28 14:34:50 +02:00
4e5520aee2 docs(buttons): added missing buttons docs 2024-08-27 20:58:05 +02:00
4591ba8f73 refactor(buttons): changed grid and thumbnail fig in gallery 2024-08-27 20:58:05 +02:00
f335763280 refactor(icons): removed toolbar icons from assets 2024-08-27 18:38:08 +02:00
e890091d86 refactor(icons): moved widget icons to class attribute ICON_NAME 2024-08-27 18:38:08 +02:00
ac2cb5197d docs(developer): tutorial for BECWidget base class 2024-08-27 18:18:10 +02:00
semantic-release
65345187b3 0.99.3
Automatically generated by python-semantic-release
2024-08-27 13:49:04 +00:00
d48243483e build: updated min version of bec qthemes 2024-08-27 13:09:21 +02:00
1ca9499edd fix(cmaps): unified all defaults to magma cmap 2024-08-27 13:09:21 +02:00
060935ffc5 fix(color maps): color maps should take the background color into account; fixed min colors to 10 2024-08-27 12:36:02 +02:00
semantic-release
50dbef52c0 0.99.2
Automatically generated by python-semantic-release
2024-08-27 09:03:23 +00:00
bb385f07ca ci: additional tests are not allowed to fail 2024-08-27 10:54:46 +02:00
cf28730515 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"
2024-08-27 10:54:46 +02:00
semantic-release
13ae383455 0.99.1
Automatically generated by python-semantic-release
2024-08-27 07:28:47 +00:00
2265458dcc fix(crosshair): emit all crosshair events, not just line coordinates 2024-08-26 14:10:46 +02:00
semantic-release
0a59f08fcc 0.99.0
Automatically generated by python-semantic-release
2024-08-25 11:49:52 +00:00
c70724a456 refactor(darkmodebutton): renamed set_dark_mode_enabled to toggle_dark_mode 2024-08-25 13:45:56 +02:00
406c263746 docs(darkmodebutton): added dark mode button docs 2024-08-25 13:45:56 +02:00
df35aabff3 test(dark_mode_button): added tests for dark mode button 2024-08-25 13:45:56 +02:00
cc8c166b5c feat(darkmodebutton): added button to toggle between dark and light mode 2024-08-25 13:45:56 +02:00
c4f3308dc0 fix(toggle): emit state change 2024-08-25 13:45:56 +02:00
semantic-release
8f3824c0e7 0.98.0
Automatically generated by python-semantic-release
2024-08-25 11:45:36 +00:00
afdf4e8782 fix(toolbar): removed hardcoded color values 2024-08-23 23:00:49 +02:00
2a82032644 fix: transitioning to material icons 2024-08-23 22:40:21 +02:00
88a2f66758 fix(dock_area): transitioned to MaterialIconAction 2024-08-23 22:05:56 +02:00
3f3b207295 fix: fix color palette if qtheme was not called 2024-08-23 20:14:53 +02:00
44cfda1c07 refactor(waveform): use set theme for demo 2024-08-23 20:04:44 +02:00
e42b84c636 fix(figure): removed theme from figure init 2024-08-23 20:04:44 +02:00
77c5aa741c fix: use globally set theme instead of the internal bec widgets theme 2024-08-23 20:04:44 +02:00
2b4449afeb feat(themes): added set_theme method 2024-08-23 20:04:44 +02:00
36ad464159 fix(waveform): fixed icon appearance 2024-08-23 20:04:44 +02:00
semantic-release
e8ae6f2e43 0.97.0
Automatically generated by python-semantic-release
2024-08-23 13:06:31 +00:00
3ecbd60627 fix(toolbar icon): fixed material icon toolbar for theme changes 2024-08-23 14:14:40 +02:00
82a55ddf3e feat(designer): added designer icon factory 2024-08-23 14:12:33 +02:00
semantic-release
7d190719b1 0.96.3
Automatically generated by python-semantic-release
2024-08-23 07:47:59 +00:00
8c2e7c8259 fix: minor fixes for type annotations 2024-08-22 20:44:28 +02:00
dd7c71bb1e docs(dispatcher): docs added 2024-08-22 14:52:52 +02:00
semantic-release
7b5b7a8cbb 0.96.2
Automatically generated by python-semantic-release
2024-08-22 09:49:04 +00:00
af28574bd5 fix(waveform): validation of custom curves removed 2024-08-22 11:35:27 +02:00
617db36ed4 fix(waveform): skip validation for curves that are not BECCurve instances 2024-08-22 10:55:49 +02:00
semantic-release
ebc2e44c7c 0.96.1
Automatically generated by python-semantic-release
2024-08-22 08:41:33 +00:00
44738057a3 fix(crosshair): update markers if necessary 2024-08-22 10:32:35 +02:00
f98a9f9771 fix(waveform_widget): fixed icon appearance 2024-08-22 10:32:35 +02:00
2fe72c9ccb fix: bubble-up signals 2024-08-22 10:32:35 +02:00
f0203d9bf6 ci: fail pytest after 2 failed tests 2024-08-22 10:32:35 +02:00
37835cbf76 fix(crosshair): fixed crosshair for image and waveforms 2024-08-22 10:32:35 +02:00
semantic-release
e005be33d1 0.96.0
Automatically generated by python-semantic-release
2024-08-22 07:50:58 +00:00
9d7718c3d9 docs(scan_control): added designer options 2024-08-22 09:42:00 +02:00
9d8fb0b761 feat(scan_control): added the ability to configure the scan control widget from designer 2024-08-22 09:42:00 +02:00
semantic-release
9df1e0899b 0.95.1
Automatically generated by python-semantic-release
2024-08-22 07:36:54 +00:00
640464a654 fix(docs): changed link to scan gui config in main docs 2024-08-21 21:46:44 +02:00
84abe46050 refactor: removed designer pngs 2024-08-21 21:28:32 +02:00
1d2afaa09e refactor: moved to dynamically loaded material design icons 2024-08-21 21:28:32 +02:00
2bf5c7096e docs: links section added 2024-08-21 21:07:50 +02:00
semantic-release
41dc6e6cfd 0.95.0
Automatically generated by python-semantic-release
2024-08-21 13:50:10 +00:00
650039303a fix(device_browser): fixed plugin assignment for designer 2024-08-21 15:41:23 +02:00
196504b533 feat(cli): added device_browser to cli 2024-08-21 15:29:22 +02:00
2c31cc90ae docs(device_browser): added user docs 2024-08-21 15:29:02 +02:00
e870e5ba08 test: added test for device browser 2024-08-21 14:38:34 +02:00
73f5a2f085 feat(widgets): added device_browser widget 2024-08-21 14:38:34 +02:00
4790afde3d refactor(docs): review response 2024-08-21 13:18:48 +02:00
7357f3d2a1 docs(user): widget gallery with documentation added 2024-08-21 13:18:48 +02:00
e9ecd268c6 docs: added sphinx-inline-tabs as sphinx dependency 2024-08-21 13:18:48 +02:00
91ba30e8d0 docs(cards): changed index cards to custom css class instead of overwriting the default sd-card theme 2024-08-21 13:18:48 +02:00
semantic-release
d36d801ef1 0.94.7
Automatically generated by python-semantic-release
2024-08-20 13:17:23 +00:00
939f834a26 fix: formatting of stdout, stderr captured text for logger 2024-08-14 18:01:51 +02:00
semantic-release
bee51bd86e 0.94.6
Automatically generated by python-semantic-release
2024-08-14 15:05:11 +00:00
bc2abe945f fix(server): emit heartbeat with state 2024-08-14 16:55:04 +02:00
semantic-release
49a5a23d41 0.94.5
Automatically generated by python-semantic-release
2024-08-14 12:01:45 +00:00
4f96d0e4a1 build: increased min version of bec to 2.21.4
Since we now rely on reusing the BECClient singleton, we need the fix introduced with 2.21.4 in BEC.
2024-08-14 12:32:34 +02:00
ea9240d2f7 fix(rpc): use client singleton instead of dispatcher 2024-08-14 12:32:34 +02:00
4d02b42f11 fix: removed qcoreapplication for polling events 2024-08-14 12:32:34 +02:00
semantic-release
9509be14be 0.94.4
Automatically generated by python-semantic-release
2024-08-14 08:46:53 +00:00
198c1d1064 fix: do not shutdown client in "close"
Terminating client connections has to be done at the application level
2024-08-13 12:23:51 +02:00
2af5c94913 docs: review developer section; add introduction 2024-08-13 11:06:24 +02:00
semantic-release
a4a0bac3c1 0.94.3
Automatically generated by python-semantic-release
2024-08-13 09:03:11 +00:00
f285b35b49 test(waveform_widget): added tests for axis setting and curve dialog 2024-08-13 10:53:44 +02:00
7aeb2b5c26 fix(curve_dialog): async curves are shown in curve dialog after addition. 2024-08-13 10:53:44 +02:00
d56ea95ef9 fix(waveform): async device entry is correctly passed, updated and with new scan the previous data are cleared 2024-08-13 10:53:44 +02:00
semantic-release
5733fea98c 0.94.2
Automatically generated by python-semantic-release
2024-08-13 08:53:10 +00:00
98b79aac7b fix(image): image is single image mode do not raise popup error when connected twice with the same monitor 2024-08-12 11:24:08 +02:00
semantic-release
4212fe0e32 0.94.1
Automatically generated by python-semantic-release
2024-08-12 08:53:48 +00:00
93d397759c fix: issue #292, wrong key was used to clean _slots internal dictionary 2024-08-12 10:32:35 +02:00
semantic-release
8c5b901a37 0.94.0
Automatically generated by python-semantic-release
2024-08-08 14:59:14 +00:00
0273bf4856 refactor: adjust dimensions 2024-08-08 15:11:43 +02:00
c80a7cd108 feat: add PositionerControlLine 2024-08-08 14:58:16 +02:00
semantic-release
a50d9c7b3f 0.93.5
Automatically generated by python-semantic-release
2024-08-08 11:48:07 +00:00
281633deff fix(positioner_box): icons fixed 2024-08-08 13:34:07 +02:00
0d190c5c59 refactor: add button for positioner selection 2024-08-08 13:34:07 +02:00
6269009e54 test(dap): wait for fit 2024-08-07 20:19:37 +02:00
6d2442d23c test(auto-update): wait for rendering 2024-08-07 19:58:26 +02:00
semantic-release
110b27351b 0.93.4
Automatically generated by python-semantic-release
2024-08-07 16:21:43 +00:00
37aa371e7c fix: rename DeviceBox to PositionerBox, fix test for validation 2024-08-07 17:56:48 +02:00
eb54e9f788 fix: add validation for bec_lib.device.Positioner; closes #268 2024-08-07 15:45:39 +02:00
semantic-release
482efeb340 0.93.3
Automatically generated by python-semantic-release
2024-08-07 13:14:54 +00:00
99ee545e41 fix(dock): properly shut down docks and temp areas 2024-08-07 13:58:43 +02:00
cf94599c25 test: removed quit from teardown 2024-08-07 12:25:54 +02:00
b50b3a27e6 fix(settings): shut down settings dialog 2024-08-07 12:25:54 +02:00
bf6294ecbf test: removed explicit call to close the widget 2024-08-07 12:25:54 +02:00
a3d4f5ac4b fix(website): fixed teardown of website widgets 2024-08-07 11:15:14 +02:00
bc264975b1 fix(dock): properly shut down docks and dock areas 2024-08-07 11:00:25 +02:00
ad07bbf85e fix(figure): cleanup pyqtgraph 2024-08-07 10:12:49 +02:00
9856857f4c test: use factory instead of fixture to properly cleanup widgets on teardown 2024-08-07 10:12:49 +02:00
f9e5897900 test: ensure all toplevelwidgets are closed 2024-08-07 10:12:49 +02:00
semantic-release
39fb22b716 0.93.2
Automatically generated by python-semantic-release
2024-08-07 07:57:18 +00:00
a372925fff fix(scan_group_box): Scan Spinboxes limits increased to max allowed values; setting dialog for step size and decimal precision for ScanDoubleSpinBox on right click 2024-08-07 09:47:06 +02:00
semantic-release
ec54440569 0.93.1
Automatically generated by python-semantic-release
2024-08-06 21:56:54 +00:00
af86860bf3 fix(dock): docks have more recognizable red icon for closing docks 2024-08-06 19:23:31 +02:00
302ae90139 docs: added video tutorial section with BSEG YT video 2024-08-06 17:42:15 +02:00
semantic-release
1405068925 0.93.0
Automatically generated by python-semantic-release
2024-08-05 14:11:40 +00:00
5aad401ef8 feat(themes): moved themes to bec_qthemes
This reverts commit fd6ae91993
2024-08-05 14:24:05 +02:00
semantic-release
885dcfda89 0.92.5
Automatically generated by python-semantic-release
2024-08-05 12:20:01 +00:00
30fef929cf fix(spinner): stop timer on close event 2024-08-05 13:54:20 +02:00
1f30dd73a9 fix(status_box): fix cleanup of status box 2024-08-05 13:54:20 +02:00
73cd11e472 test: register all widgets with qtbot and close them 2024-08-05 13:54:20 +02:00
7616ca0e14 refactor(queue): refactored bec queue to inherit only from qwidget 2024-08-05 13:54:20 +02:00
semantic-release
ca29a69779 0.92.4
Automatically generated by python-semantic-release
2024-07-31 07:24:44 +00:00
dcc5fd71ee fix: fix missmatch of signal/slot in image and motormap 2024-07-29 16:05:21 +02:00
semantic-release
fee4901657 0.92.3
Automatically generated by python-semantic-release
2024-07-28 10:05:23 +00:00
71873ddf35 fix(docs): moved to pyside6 2024-07-28 11:17:17 +02:00
semantic-release
f8552ca551 0.92.2
Automatically generated by python-semantic-release
2024-07-28 08:53:31 +00:00
995a795060 fix(widgets): fixed import for tictactoe example 2024-07-28 10:42:32 +02:00
semantic-release
7ab81c5797 0.92.1
Automatically generated by python-semantic-release
2024-07-28 07:04:29 +00:00
bc1e23944c fix: use SafeSlot instead of Slot 2024-07-28 08:54:24 +02:00
a3fe20500a fix: linting 2024-07-28 08:54:24 +02:00
61a4e32deb fix: always add a QApplication for tests 2024-07-28 08:54:24 +02:00
3d681f77e1 fix: add xvfb to draw offscreen 2024-07-28 08:54:24 +02:00
5a9ccfd1f6 fix: reset ErrorPopup singleton between tests 2024-07-26 11:58:07 +02:00
fc57b7a126 fix: metaclass + QObject segfaults PyQt(cpp bindings) 2024-07-26 11:58:07 +02:00
06205e0790 build(ci): install ophyd_devices in editable mode for pipelines 2024-07-25 09:46:58 +02:00
4be6fd6b83 refactor: renamed DeviceMonitor2DMessage 2024-07-25 09:46:58 +02:00
714e1e139e refactor: rename device_monitor to device_monitor_2d 2024-07-25 09:46:58 +02:00
semantic-release
01c0e0b1df 0.92.0
Automatically generated by python-semantic-release
2024-07-24 18:52:56 +00:00
4457ef2147 fix(dock): custom label can be created closable 2024-07-23 22:22:16 +02:00
8ca60d54b3 feat(dock): dock style sheets updated 2024-07-23 22:22:16 +02:00
5696c993dc feat(general_gui): general gui added 2024-07-23 22:22:16 +02:00
1206e15309 fix(device_combobox): set minimum size to 125px 2024-07-23 22:22:16 +02:00
semantic-release
4f2b51b211 0.91.0
Automatically generated by python-semantic-release
2024-07-23 19:59:21 +00:00
1b9c55a46a fix(status_item): icons changed to material design 2024-07-23 20:41:02 +02:00
f4844d2e06 fix(plugins): Qt Designer plugins icons adjusted 2024-07-23 20:41:02 +02:00
06fab0eab9 test(dock_area): tests extended 2024-07-23 20:40:36 +02:00
a16b87ac28 feat(dock_area): plugin added 2024-07-23 20:40:36 +02:00
cce1367a72 feat(dock_area): Added toolbar to dock area to add widgets without CLI interactions 2024-07-23 20:40:36 +02:00
28f26e92a4 feat(toolbar): expandable menu actions 2024-07-23 20:40:36 +02:00
semantic-release
2f5cc3030d 0.90.0
Automatically generated by python-semantic-release
2024-07-23 16:26:53 +00:00
1cf6e32303 fix(axis_setting): fix compatibility for issue with horizontal line for PyQt6 2024-07-23 18:17:35 +02:00
7f49893d2c fix(image_widget): image_widget autorange fixed 2024-07-23 18:17:35 +02:00
ba0d1ea903 refactor(jupyter_console_example): added examples of standalone widgets 2024-07-23 18:17:35 +02:00
70fb276fdf test(image_widget): tests added 2024-07-23 18:17:35 +02:00
43711680ba feat(image_widget): plugin added 2024-07-23 18:17:35 +02:00
3d2ca4855c fix(image_widget): image widget adjusted 2024-07-23 18:17:35 +02:00
fe7e542b19 fix(image): only single monitor image is allowed 2024-07-23 18:17:35 +02:00
501eb923f1 feat(image_widget): all toolbar actions added 2024-07-23 18:17:35 +02:00
c15035b6b7 fix(image): raw data are saved in image item to always have precise processing 2024-07-23 18:17:35 +02:00
6a9317facd feat(image_widget): image_widget added 2024-07-23 18:17:35 +02:00
semantic-release
95e515114a 0.89.0
Automatically generated by python-semantic-release
2024-07-22 19:43:00 +00:00
fd6ae91993 Revert "feat(themes): moved themes to bec_qthemes"
This reverts commit 3798714369
2024-07-22 21:35:07 +02:00
3798714369 feat(themes): moved themes to bec_qthemes 2024-07-22 21:30:35 +02:00
semantic-release
067496b18c 0.88.1
Automatically generated by python-semantic-release
2024-07-22 15:56:01 +00:00
ad112d1f08 refactor(toolbar): generalizations of the ToolBarAction 2024-07-19 18:35:44 +02:00
a3dff7decc fix(plot_base): set_xy autorange moved to plotbase from waveform 2024-07-19 18:35:43 +02:00
2bcaa4256d docs: readthedocs icon path fixed 2024-07-19 16:40:45 +02:00
semantic-release
006f7c01fd 0.88.0
Automatically generated by python-semantic-release
2024-07-19 14:33:09 +00:00
2c8764a27d fix(waveform_widget): plot API unified with BECFigure 2024-07-19 16:12:36 +02:00
50135b5fe9 fix(colormap_selector): compatibility for PyQt6 when using designer fixed 2024-07-18 21:13:13 +02:00
8d764e2d46 test(waveform_widget): test added 2024-07-18 21:13:13 +02:00
6eb313fa76 fix(waveform_widget): adapted for BECWidget base class 2024-07-18 15:50:45 +02:00
1f8ef52b60 feat(waveform_widget): designer plugin added 2024-07-18 15:44:52 +02:00
2be009c647 feat(waveform_widget): switch between drag and rectangle mode 2024-07-18 15:44:52 +02:00
8df6b003e5 feat(waveform_widget): autorange button 2024-07-18 15:44:52 +02:00
7089cf356a fix(waveform_widget): temporary disabled save/load config 2024-07-18 15:44:52 +02:00
1e551d6e96 feat(waveform_widget): dap parameter window 2024-07-18 15:44:52 +02:00
8e588d79c8 fix(waveform_widget): use @SafeSlot decorator for automatic error message 2024-07-18 15:44:52 +02:00
8d93405399 feat(waveform): export to matplotlib window of current scene 2024-07-18 15:44:52 +02:00
6ff6111091 feat(figure): export dialog can be launched from CLI and from toolbar 2024-07-18 15:44:52 +02:00
a8b6ef20cc refactor(icons): icons moved to the assets directory 2024-07-18 15:44:52 +02:00
a8ff1d4cd0 feat(waveform_widget): added error handle utility 2024-07-18 15:44:52 +02:00
47fcb9ebfe refactor(waveform_widget): removed PYSIDE6 check 2024-07-18 15:44:52 +02:00
e8305652fd feat(curve_dialog): add DAP functionality 2024-07-18 15:44:52 +02:00
33495cfe03 fix(waveform): colormaps of curves can be changed and normalised
feat(waveform): colormap can be changed from curve dialog

fix(curve_dialog): default dialog parameters fixed

curve Dialog colormap WIP
2024-07-18 15:44:52 +02:00
8ac35d7280 fix(waveform_widget): adapted for changes from improved scan logic from waveform widget 2024-07-18 15:44:52 +02:00
c926a75a79 feat(curve_dialog): curves can be added 2024-07-18 15:44:52 +02:00
fa9b17191d feat(waveform_widget): BECWaveformWidget toolbar added import/export config 2024-07-18 15:44:52 +02:00
755b394c1c feat(waveform_widget): BECWaveformWidget added with toolbar 2024-07-18 15:44:52 +02:00
semantic-release
717df63d62 0.87.1
Automatically generated by python-semantic-release
2024-07-18 13:42:30 +00:00
d75c55b2b1 fix(dock): added hasattr to cleanup method for widgets 2024-07-18 15:28:45 +02:00
e52ee2604c fix: add missing close() call, ensure jupyter console client.shutdown() is called in closeEvent 2024-07-18 11:07:19 +02:00
c7feb6952d refactor: BECWidget is a mixin based on BECConnector, for each QWidget in BEC
Handles closeEvent() and RPC registering/unregistering
2024-07-18 11:07:19 +02:00
d64758f268 fix: BECWidget checks if it is a widget, and implements closeEvent and cleanup 2024-07-17 16:26:03 +02:00
6202d224fe fix: add exit handlers for BECConnection objects 2024-07-17 16:13:46 +02:00
semantic-release
4ac90e1e56 0.87.0
Automatically generated by python-semantic-release
2024-07-17 13:55:11 +00:00
8f104cf402 tests: add unit tests for error and warning message boxes 2024-07-17 13:26:39 +02:00
787f74949b feat(qt_utils): added warning utility with simple API to setup warning message 2024-07-17 13:26:38 +02:00
196ef7afe1 feat(qt_utils): added error handle utility with popup messageBoxes 2024-07-17 13:25:12 +02:00
semantic-release
18ac3ffac0 0.86.0
Automatically generated by python-semantic-release
2024-07-17 09:03:55 +00:00
ba69e7957c feat(toolbar): added separator action 2024-07-17 10:57:31 +02:00
semantic-release
5acb47532c 0.85.1
Automatically generated by python-semantic-release
2024-07-17 08:04:55 +00:00
b5b0aa4f82 fix(waveform): readout_priority dict fixed, not overwritten to 'baseline' key 2024-07-16 21:49:39 +02:00
semantic-release
9a91583ed0 0.85.0
Automatically generated by python-semantic-release
2024-07-16 10:53:17 +00:00
b98fd00ade feat(color_map_selector): added colormap selector with plugin 2024-07-15 22:26:14 +02:00
semantic-release
d5c5e12589 0.84.0
Automatically generated by python-semantic-release
2024-07-15 15:23:17 +00:00
e495fd30c4 fix(waveform): timestamp are not converted to human readable format 2024-07-15 16:51:22 +02:00
8516a1d639 fix(waveform): set_x method various bugs fixed 2024-07-14 19:32:38 +02:00
006992e43c test(waveform): tests extended 2024-07-14 19:32:36 +02:00
48911e9348 refactor(waveform): plot can be prompted without specifying kwargs 2024-07-14 19:29:32 +02:00
e4e1a905d1 fix(waveform): x axis switching logic fixed when axis are not compatible 2024-07-14 19:29:32 +02:00
fc935d9fc8 refactor(jupyter_console_window): added more examples of waveforms 2024-07-14 19:13:15 +02:00
0c6a9f2310 feat(waveform): async readback update implemented for async devices 2024-07-14 19:12:19 +02:00
d23fd8bd07 fix(waveform): dap leaked RID for all daps in current process; dap RID is now f"{scan_id}-{gui_id}" to distinguish for each plot instance 2024-07-14 19:12:19 +02:00
9d6ae87d0f fix(waveform): only one type of x axis allowed; x mode validated 2024-07-14 19:12:19 +02:00
fc5a8bdd8b fix(waveform): data for axis are taken by separate method; validation consolidated 2024-07-14 19:12:19 +02:00
b8717f1327 feat(waveform): data are taken directly from ScanItem which is defined from scan_status endpoint; scan update is triggered from scan_segment; plots can be added just specifying y_name -> best effort for setting x reported device 2024-07-14 19:12:19 +02:00
0aa317aae5 fix(bec_dispatcher): connect_slot can accept kwargs 2024-07-14 19:12:19 +02:00
semantic-release
edc19bdff8 0.83.1
Automatically generated by python-semantic-release
2024-07-14 17:09:51 +00:00
11a7204c98 test(toolbar): added reference pngs for spinner for Darwin 2024-07-14 16:49:33 +02:00
eab7883979 fix(toolbar): default transparent background 2024-07-14 16:49:04 +02:00
2d4249e73a fix: use apply_theme 2024-07-10 15:36:32 +02:00
63db1352ee fix: spinner: update reference image for widget test, use apply_theme 2024-07-10 15:36:06 +02:00
8308115f36 fix: replace pyqtdarktheme by qdarkstyle, add 'apply_theme' function (in utils/colors.py) 2024-07-10 15:36:06 +02:00
semantic-release
02fce838db 0.83.0
Automatically generated by python-semantic-release
2024-07-08 22:48:46 +00:00
360d171355 fix(terminal): added default args to avoid designer crashes on startup 2024-07-09 00:40:15 +02:00
eb26e2a11b test(vscode): fixed vscode tests for new cleanup routine 2024-07-09 00:40:15 +02:00
2b29e34b52 fix(widget): fixed widget cleanup routine 2024-07-09 00:40:15 +02:00
fd8766ed87 fix(bec_widget): added cleanup method to bec widget base class 2024-07-09 00:40:15 +02:00
5de8804da1 test(vscode): improved vscode test 2024-07-09 00:40:15 +02:00
2988fd387e feat: added reference utils to compare renderings of widgets 2024-07-09 00:40:15 +02:00
1b017edfad feat(widgets): added device box with spinner 2024-07-09 00:40:15 +02:00
903ce7d46b fix(website): fixed dummy input 2024-07-09 00:40:15 +02:00
41bcb80167 feat(designer): added option to skip the widget validation for DesignerPluginGenerator 2024-07-09 00:40:15 +02:00
semantic-release
d3f85060ca 0.82.2
Automatically generated by python-semantic-release
2024-07-08 14:24:06 +00:00
90178e2f61 fix(rpc_server): pass cli config to server 2024-07-07 23:25:45 +02:00
semantic-release
b9f9a003a2 0.82.1
Automatically generated by python-semantic-release
2024-07-07 21:12:19 +00:00
734f4c7750 tests(motor_map_widget): tests added 2024-07-07 22:59:02 +02:00
c78cd898f2 fix(motor_map): bug where motors without limits were selected 2024-07-07 22:59:02 +02:00
74a249bd06 test(setting_dialog): tests added 2024-07-07 22:59:02 +02:00
2020953b93 feat(settings_dialog):apply button 2024-07-07 22:59:02 +02:00
3826bb3d9e refactor(setting_dialog): moved to qt_utils 2024-07-07 22:59:02 +02:00
7ffc06f3c7 refactor(toolbar): toolbar moved from widgets to qt_utils 2024-07-07 22:59:02 +02:00
semantic-release
eea1a75d4a 0.82.0
Automatically generated by python-semantic-release
2024-07-07 20:58:30 +00:00
b9bff38b64 feat(toggle): added angular component-like toggle 2024-07-07 22:50:25 +02:00
f04862933f refactor(device_input): DeviceComboBox and DeviceLineEdit moved to top layer of widgets 2024-07-07 18:53:32 +02:00
f5b8375fd3 refactor(stop_button): moved to top layer, plugin added 2024-07-07 18:53:32 +02:00
db1cdf4280 refactor(motor_map_widget): removed restriction of only PySide6 for widget 2024-07-07 18:42:51 +02:00
fa1e86ff07 refactor(color_button): ColorButton moved to top level of widgets 2024-07-07 18:42:51 +02:00
semantic-release
9a95454723 0.81.2
Automatically generated by python-semantic-release
2024-07-07 16:28:57 +00:00
dd1875ea5c fix(waveform): scan_history error check for IndexError 2024-07-07 18:21:08 +02:00
semantic-release
df4fabb32a 0.81.1
Automatically generated by python-semantic-release
2024-07-07 11:45:09 +00:00
99114f14f6 fix(motor_control): temporary remove of motor control widgets 2024-07-07 11:41:07 +02:00
semantic-release
fc3a69bbb0 0.81.0
Automatically generated by python-semantic-release
2024-07-06 12:24:27 +00:00
9594be2606 feat(color_button): can get colors in RGBA or HEX 2024-07-06 12:31:29 +02:00
semantic-release
96fd239608 0.80.1
Automatically generated by python-semantic-release
2024-07-06 10:24:27 +00:00
61de7e9e22 fix(entry_validator): check for entry == "" 2024-07-06 12:16:29 +02:00
semantic-release
24c4cdc39f 0.80.0
Automatically generated by python-semantic-release
2024-07-06 10:14:47 +00:00
fadbf77866 feat(qt5): dropped support for qt5; pyside2 and pyqt5 2024-07-06 12:00:35 +02:00
03819a3d90 feat(plugins): moved plugin dict to dataclass and container 2024-07-06 12:00:35 +02:00
d6d0777113 feat(plugins): added support for pyqt6 ui files 2024-07-05 22:50:19 +02:00
1aa83e0ef1 feat(plugins): added bec widgets base class 2024-07-05 21:22:51 +02:00
semantic-release
b09c644e01 0.79.3
Automatically generated by python-semantic-release
2024-07-05 08:25:43 +00:00
e403870874 fix: changed inheritance to adress qt designer bug in rendering 2024-07-05 08:06:10 +02:00
1586ce2d6c fix: add designer plugin classes 2024-07-05 08:05:20 +02:00
576353cfe8 refactor: simplify logic in bec_status_box 2024-07-05 08:05:20 +02:00
semantic-release
cefc415c98 0.79.2
Automatically generated by python-semantic-release
2024-07-04 22:45:13 +00:00
bc0ef7893e fix: overwrite closeEvent and call super class 2024-07-04 17:27:29 +02:00
semantic-release
0e802d8194 0.79.1
Automatically generated by python-semantic-release
2024-07-03 09:34:03 +00:00
d7718d4dcb fix: use libdir env var to preload Python library, also for Linux platform 2024-07-03 11:07:30 +02:00
semantic-release
4c2e02e912 0.79.0
Automatically generated by python-semantic-release
2024-07-03 08:45:07 +00:00
b8774e0b0b fix(toolbar): change default color to black to match BECFigure theme 2024-07-03 10:34:05 +02:00
6e75642090 feat(motor_map_widget): standalone MotorMap Widget with toolbar + plugin 2024-07-03 10:34:05 +02:00
aaa0d1003d fix(motor_map): fixed bug with residual trace after changing motors 2024-07-03 10:34:05 +02:00
5960918137 feat(motor_map): method to reset history trace 2024-07-03 10:34:05 +02:00
3dc0532df0 fix(widget_io): widget handler adjusted for spinboxes and comboboxes 2024-07-03 10:34:05 +02:00
96863adf53 refactor(toolbar): cleanup and adjusted colors 2024-07-03 10:34:05 +02:00
semantic-release
08425a623e 0.78.1
Automatically generated by python-semantic-release
2024-07-02 21:06:54 +00:00
b787759f44 fix(ui_loader): ui loader is compatible with bec plugins 2024-07-02 22:07:24 +02:00
semantic-release
25ef7c05e6 0.78.0
Automatically generated by python-semantic-release
2024-07-02 20:03:45 +00:00
c36bb80d6a feat(color_button): patched ColorButton from pyqtgraph to be able to be opened in another QDialog 2024-07-02 20:54:39 +02:00
semantic-release
c069f3e1b3 0.77.0
Automatically generated by python-semantic-release
2024-07-02 11:00:18 +00:00
215d59c8bf fix(waveform): scatter 2D brush error 2024-07-02 12:43:56 +02:00
008a33a9b1 fix(figure): API cleanup 2024-07-02 12:43:38 +02:00
3e787234c7 fix(figure): if/else logic corrected in subplot_factory 2024-07-02 12:43:38 +02:00
1173510105 fix(image): processing of already displayed data; closes #106 2024-07-02 12:43:38 +02:00
a391f3018c feat(bec_connector): export config to yaml 2024-07-02 12:43:38 +02:00
b6e1e20b7c fix(bec_figure): full reconstruction with config from other bec figure 2024-07-02 12:43:38 +02:00
572f2fb811 feat(utils): colors added convertor for rgba to hex 2024-07-02 12:43:38 +02:00
2e2d422910 fix(motor_map): API changes updates current visualisation; motor_map can be initialised from config 2024-07-02 12:43:38 +02:00
f0556e4411 fix(image): image add_custom_image fixed, closes #225 2024-07-02 12:43:38 +02:00
4a97105e4b fix(figure): subplot methods consolidated; added subplot factory 2024-07-02 12:43:38 +02:00
797f73c39a fix(image): image can be fully reconstructed from config 2024-07-02 12:43:38 +02:00
b8f796fd3f fix(image_item): vrange added int for pydantic model check 2024-07-02 12:43:38 +02:00
78673ea11a fix(bec_figure): waveforms can be initialised from the config; widgets are deleteLater after removal 2024-07-02 12:43:38 +02:00
c6a14c0768 Resolve "add VT100 console executing BEC as a widget" 2024-07-01 15:11:09 +02:00
semantic-release
70a966d8dc 0.76.1
Automatically generated by python-semantic-release
2024-06-29 12:17:07 +00:00
c42511dd44 fix(plugins): fixes and tests for auto-gen plugins 2024-06-28 13:49:12 +02:00
semantic-release
db62f9e998 0.76.0
Automatically generated by python-semantic-release
2024-06-28 10:20:18 +00:00
0610d2f9f0 fix: fixed qwidget inheritance for ring progress bar 2024-06-28 12:12:18 +02:00
c1dd0ee190 feat(designer): added support for creating designer plugins automatically 2024-06-28 12:12:18 +02:00
a45c407568 fix:parent set as first kwarg TextBox and WebsiteWidget 2024-06-27 17:47:08 +02:00
semantic-release
813f57861c 0.75.0
Automatically generated by python-semantic-release
2024-06-26 19:38:24 +00:00
3faee98ec8 feat(widgets): added simple bec queue widget 2024-06-26 20:42:37 +02:00
ca02132c8d refactor(dispatcher): cleanup 2024-06-26 20:42:37 +02:00
semantic-release
cb4ef25b73 0.74.1
Automatically generated by python-semantic-release
2024-06-26 13:10:06 +00:00
c8b7367815 fix(rings): rings properties updated right after setting 2024-06-26 11:46:21 +02:00
a268caaa30 test(bec_figure): tests for removing widgets with rpc e2e 2024-06-26 11:41:29 +02:00
6b25abff70 fix(motor_map): motor map can be removed from BECFigure with .remove() 2024-06-26 11:24:46 +02:00
21c807f358 chore: sorted dependencies alphabetically 2024-06-26 10:27:46 +02:00
56fdae4275 build: added missing pytest-bec-e2e dependency; closes #219 2024-06-26 10:24:25 +02:00
e6a06c9f43 build: fixed dependency ranges; closes #135 2024-06-26 10:09:48 +02:00
f979a63d3d docs: fixed doc string 2024-06-26 09:46:14 +02:00
semantic-release
327bc54e22 0.74.0
Automatically generated by python-semantic-release
2024-06-25 16:44:56 +00:00
a51b15da3f docs(becfigure): docs added 2024-06-25 18:37:23 +02:00
7271b422f9 test(waveform1d): dap e2e test added 2024-06-25 18:37:23 +02:00
1866ba66c8 feat(waveform1d): dap LMFit model can be added to plot 2024-06-25 18:37:20 +02:00
semantic-release
6175a04a90 0.73.2
Automatically generated by python-semantic-release
2024-06-25 16:24:11 +00:00
7120f3e93b fix(vscode): only run terminate if the process is still alive 2024-06-25 18:17:02 +02:00
acc13183e2 fix(rpc): trigger shutdown of server when gui is terminated 2024-06-25 16:45:39 +02:00
f75fc19c5b fix(rpc): remove of calling "close" and waiting for gui_is_alive 2024-06-25 15:22:29 +02:00
semantic-release
2650c8b8cf 0.73.1
Automatically generated by python-semantic-release
2024-06-25 10:25:13 +00:00
1de3cbf65a fix(ringprogressbar): removed hard-coded endpoint strings 2024-06-25 12:18:14 +02:00
semantic-release
4a9d0c9e44 0.73.0
Automatically generated by python-semantic-release
2024-06-25 10:05:53 +00:00
88ecd05b95 test: add test for imageitem 2024-06-25 11:58:55 +02:00
df812eaad5 feat: add new default scaling of image_item 2024-06-25 11:58:55 +02:00
semantic-release
d62da494c8 0.72.2
Automatically generated by python-semantic-release
2024-06-25 09:23:32 +00:00
e631fc15d8 fix(designer): fixed designer for pyenv and venv; closes #237 2024-06-24 18:38:53 +02:00
semantic-release
ecbf1ce0c8 0.72.1
Automatically generated by python-semantic-release
2024-06-24 14:42:07 +00:00
e5c0087c9a fix: renamed spiral progress bar to ring progress bar; closes #235 2024-06-24 16:37:36 +02:00
4348ed1bb2 test: bugfix to prohibit leackage of mock 2024-06-24 14:04:42 +02:00
semantic-release
5c11fde0a9 0.72.0
Automatically generated by python-semantic-release
2024-06-24 11:47:52 +00:00
4ca1efeeb8 feat(connector): added threadpool wrapper 2024-06-24 13:41:10 +02:00
aa7ce2ea27 tests(status_box_test): temporary disabled tests for status_box due to high rate of failures 2024-06-24 13:15:59 +02:00
semantic-release
174f0cdcb6 0.71.1
Automatically generated by python-semantic-release
2024-06-23 15:30:25 +00:00
860517a321 fix: don't print exception if the auto-update module cannot be found in plugins 2024-06-23 17:23:39 +02:00
semantic-release
66daae6d9e 0.71.0
Automatically generated by python-semantic-release
2024-06-23 12:33:09 +00:00
83001a0d82 test(scan_control):e2e tests added 2024-06-23 12:53:15 +02:00
1b7921a7f2 doc(scan_control): docs added 2024-06-23 12:12:22 +02:00
8badb6adc1 fix(cleanup): cleanup added to device_input widgets and scan_control 2024-06-23 12:12:20 +02:00
37682e7b8a fix(scan_group_box): added row counter based on widgets 2024-06-23 12:11:30 +02:00
56e74a0e7d test(scan_control): tests added 2024-06-23 12:11:30 +02:00
ec4574ed5c fix(scan_control): added default min limit for args bundle if specified 2024-06-23 12:11:30 +02:00
21d20e0fc7 fix(device_line_edit):SizePolicy fixed for 100 horizontal 2024-06-23 12:11:30 +02:00
7ce3a83c58 fix(scan_control): argbox delete later added to prevent overlapping gui if scan changed 2024-06-23 12:11:30 +02:00
6dff1879c4 fix(scan_control): only scans with defined gui_config are allowed 2024-06-23 12:11:30 +02:00
c09644b29d tests WIP 2024-06-23 12:11:30 +02:00
d8cf44134c feat(scan_group_box): scan box for args and kwargs separated from ScanControlGUI code 2024-06-23 12:11:30 +02:00
ca856384f3 fix(WidgetIO): find handlers within base classes 2024-06-23 12:11:30 +02:00
4e2c9df6a4 refactor(device_line_edit): renamed default_device to default 2024-06-23 12:11:27 +02:00
8b822e0fa8 fix(scan_control): adapted widget to scan BEC gui config 2024-06-23 12:10:33 +02:00
67d398caf7 fix(scan_control): scan_control.py combatible with the newest BEC versions, test disabled 2024-06-23 12:10:33 +02:00
semantic-release
c2c27f8279 0.70.0
Automatically generated by python-semantic-release
2024-06-21 17:22:25 +00:00
50b3422528 fix(bec-desiger+plugins): imports fixed, PYSIDE6 check to not enable run plugins with pyqt6 2024-06-21 18:34:20 +02:00
4639eee0b9 feat(bec-designer): automatic plugin discovery 2024-06-21 18:34:20 +02:00
b4b27aea3d feat(device_line_edit): plugin added to bec-designer 2024-06-21 17:40:53 +02:00
e483b282db feat(device_combobox): plugin added to bec-designer 2024-06-21 17:30:02 +02:00
36391db607 feat: added entry point for bec-designer 2024-06-21 16:46:04 +02:00
5362334ff3 feat(utils/bec-designer): added startup script to launched QtDesigner compatible with conda environments 2024-06-21 16:46:04 +02:00
fdf11d8147 docs: fix typo in link 2024-06-21 14:44:50 +02:00
semantic-release
204f653b72 0.69.0
Automatically generated by python-semantic-release
2024-06-21 12:25:10 +00:00
48ae950d57 feat(widgets): added vscode widget 2024-06-21 13:47:05 +02:00
925c893f3f fix(generate_cli): fixed rpc generate for classes without user access; closes #226 2024-06-21 13:47:05 +02:00
semantic-release
b54423a151 0.68.0
Automatically generated by python-semantic-release
2024-06-21 11:35:42 +00:00
ce374163ca fix: ignore GUI server output (any output will go to log file)
If a logger is given to log `_start_log_process`, the server stdout and
stderr streams will be redirected as log entries with levels DEBUG or ERROR
in their parent process
2024-06-21 12:32:59 +02:00
3644f344da feat: properly handle SIGINT (ctrl-c) in BEC GUI server -> calls qapplication.quit() 2024-06-21 12:32:59 +02:00
d1266a1ce1 feat: bec-gui-server: redirect stdout and stderr (if any) as proper debug and error log entries 2024-06-21 12:32:59 +02:00
f7d0b0768a fix: do not create 'BECClient' logger when instantiating BECDispatcher 2024-06-21 12:32:59 +02:00
630616ec72 feat: add logger for BEC GUI server 2024-06-21 12:32:59 +02:00
semantic-release
7f7bef7581 0.67.0
Automatically generated by python-semantic-release
2024-06-21 08:42:15 +00:00
d2f2b206bb refactor: Change inheritance to QTreeWidget from QWidget 2024-06-21 10:27:15 +02:00
6fa1c06053 docs: add widget to documentation 2024-06-21 10:26:14 +02:00
5d4ca816cd test: add test suite for bec_status_box and status_item 2024-06-20 18:08:26 +02:00
443b6c1d7b feat: introduce BECStatusBox Widget 2024-06-20 17:45:44 +02:00
505a5ec833 Update file requirements.txt 2024-06-20 17:28:53 +02:00
semantic-release
3a7289bf5e 0.66.1
Automatically generated by python-semantic-release
2024-06-20 14:49:13 +00:00
2718bc6247 fix: fixed shutdown for pyside 2024-06-20 16:31:16 +02:00
semantic-release
515d2651bf 0.66.0
Automatically generated by python-semantic-release
2024-06-20 12:19:39 +00:00
ef25f56380 feat(rpc): discover widgets automatically 2024-06-20 14:04:30 +02:00
semantic-release
5b280ccc1e 0.65.2
Automatically generated by python-semantic-release
2024-06-20 12:04:07 +00:00
cbbd23aa33 fix(pyqt): webengine must be imported before qcoreapplication 2024-06-20 13:50:44 +02:00
semantic-release
860d0ad014 0.65.1
Automatically generated by python-semantic-release
2024-06-20 08:15:53 +00:00
fa344a5799 fix: prevent segfault by closing the QCoreApplication, if any 2024-06-20 10:05:23 +02:00
semantic-release
3919de5bd5 0.65.0
Automatically generated by python-semantic-release
2024-06-20 07:36:53 +00:00
1a0a98a453 test(device_input): tests added 2024-06-20 09:25:43 +02:00
d79f7e9ccd fix(device_input_base): bug with setting config and overwriting default device and filter 2024-06-20 09:25:43 +02:00
50e41ff261 feat(device_input): DeviceLineEdit with QCompleter added 2024-06-20 09:25:43 +02:00
430b282039 feat(device_combobox): DeviceInputBase and DeviceComboBox added 2024-06-20 09:25:43 +02:00
semantic-release
17133771bb 0.64.2
Automatically generated by python-semantic-release
2024-06-19 14:50:20 +00:00
e5a7d47b21 fix(client_utils): added close rpc command to shutdown of gui from bec_ipython_client 2024-06-19 16:36:05 +02:00
semantic-release
71ec61e27b 0.64.1
Automatically generated by python-semantic-release
2024-06-19 11:54:08 +00:00
b3575eb068 test: moved rpc_classes test 2024-06-19 13:38:46 +02:00
216511b951 fix(widgets): removed widget module import of sub widgets 2024-06-19 13:38:46 +02:00
6dabbf874f refactor(utils): moved get_rpc_widgets to plugin_utils 2024-06-19 13:38:46 +02:00
semantic-release
d5aad06c88 0.64.0
Automatically generated by python-semantic-release
2024-06-19 11:35:37 +00:00
5d6672069e fix(plot_base): font size is set with setScale which is scaling the whole legend window 2024-06-19 13:26:10 +02:00
140ad83380 test: add tests 2024-06-19 13:26:10 +02:00
ea805d1362 feat: add option to change size of the fonts 2024-06-19 13:26:10 +02:00
9e16f2faf9 docs: fix links in developer section 2024-06-14 14:34:26 +02:00
2a36d9364f docs: refactor developer section, add widget tutorial 2024-06-14 14:24:38 +02:00
27426ce7a5 ci: add job optional dependency check 2024-06-14 11:47:42 +02:00
semantic-release
69adadd6d7 0.63.2
Automatically generated by python-semantic-release
2024-06-14 08:14:22 +00:00
6f96498de6 fix: do not import "server" in client, prevents from having trouble with QApplication creation order
Like with QtWebEngine
2024-06-13 15:14:30 +02:00
836b6e64f6 Reapply "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
This reverts commit fe04dd80e5.
2024-06-13 15:14:30 +02:00
semantic-release
fab7dd7eec 0.63.1
Automatically generated by python-semantic-release
2024-06-13 13:12:54 +00:00
9263f8ef5c fix: just terminate the remote process in close() instead of communicating
The proper finalization sequence will be executed by the remote process
on SIGTERM
2024-06-13 14:56:21 +02:00
semantic-release
658728efef 0.63.0
Automatically generated by python-semantic-release
2024-06-13 12:47:57 +00:00
6b8432f5b2 refactor: add pydantic config, add change_theme 2024-06-13 14:08:22 +02:00
bc709c4184 docs: add documentation 2024-06-13 08:14:50 +02:00
b49462abeb test: add test for text box 2024-06-13 08:14:50 +02:00
d9d4e3c9bf feat: add textbox widget 2024-06-13 08:08:46 +02:00
fe04dd80e5 Revert "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
This reverts commit abc6caa2d0
2024-06-12 17:19:08 +02:00
semantic-release
718950cf0d 0.62.0
Automatically generated by python-semantic-release
2024-06-12 10:01:48 +00:00
17a0068757 doc: add documentation about creating custom GUI applications embedding BEC Widgets 2024-06-12 11:54:47 +02:00
abc6caa2d0 feat: implement non-polling, interruptible waiting of gui instruction response with timeout 2024-06-12 11:43:08 +02:00
semantic-release
99fb82561b 0.61.0
Automatically generated by python-semantic-release
2024-06-12 06:27:41 +00:00
61ba08d0b8 feat(widgets/stop_button): General stop button added 2024-06-12 01:11:06 +02:00
40b5688158 refactor: improve labe of auto_update script 2024-06-10 08:27:32 +02:00
semantic-release
0a4e253cbd 0.60.0
Automatically generated by python-semantic-release
2024-06-08 17:35:57 +00:00
6428e38ab9 fix: removed BECConnector from rpc client interface 2024-06-08 19:05:57 +02:00
fc4f4f81ad ci: added git fetch for target branch 2024-06-08 19:05:57 +02:00
f6629852eb test: added missing pylint statement to header 2024-06-08 19:05:57 +02:00
3adf6cfd58 refactor: minor cleanup 2024-06-08 19:05:57 +02:00
b15816ca9f refactor: disabled pylint for auto-gen client 2024-06-08 19:05:57 +02:00
6b1d5827d6 ci: fixed pylint-check 2024-06-08 19:05:57 +02:00
f0391f59c9 feat: added isort to bw-generate-cli 2024-06-08 19:05:57 +02:00
006a0894b8 fix: added bec_ipython_client as dependency; needed for jupyter widget 2024-06-08 19:05:57 +02:00
9c5a471234 refactor(isort): added bec_widgets as known first party package 2024-06-08 19:05:57 +02:00
1c7f4912ce feat: added entry point for bw-generate-cli 2024-06-08 19:05:57 +02:00
df1be10057 feat(cli): auto-discover rpc-enabled widgets 2024-06-08 19:05:57 +02:00
954c576131 fix(BECFigure): removed duplicated user access for plot 2024-06-08 19:05:57 +02:00
867720a897 fix(bec_connector): field validator should be a classmethod 2024-06-08 19:05:57 +02:00
2b40602bdc refactor(dock): parent_dock_area changed to orig_area (native for pyqtgraph) 2024-06-08 16:00:45 +02:00
11173b9c0a ci: cleanup 2024-06-08 08:54:08 +02:00
semantic-release
52d46e77db 0.59.1
Automatically generated by python-semantic-release
2024-06-07 23:24:10 +00:00
e7838b0f2f fix(curve): set_color_map_z typo fixed in user access 2024-06-08 01:17:13 +02:00
semantic-release
2ae3810cf6 0.59.0
Automatically generated by python-semantic-release
2024-06-07 20:47:10 +00:00
178fe4d2da ci: merged additional tests to parallel matrix job 2024-06-07 22:37:24 +02:00
2d79ef8fe5 ci: added webengine dependencies 2024-06-07 22:37:24 +02:00
d56c5493cd build: added webengine dependency 2024-06-07 22:37:24 +02:00
cf6e5a40fc docs: added website docs 2024-06-07 22:37:24 +02:00
64abd67b9b feat(widget): added simple website widget with rpc 2024-06-07 22:37:24 +02:00
semantic-release
c19e856800 0.58.1
Automatically generated by python-semantic-release
2024-06-07 19:39:16 +00:00
02a26086c4 fix(dock): new dock can be detached upon creation 2024-06-07 21:32:38 +02:00
semantic-release
35f880bc2f 0.58.0
Automatically generated by python-semantic-release
2024-06-07 19:25:17 +00:00
c0ddeceeea test(color): validation tests added 2024-06-07 19:16:58 +02:00
67fd5e8581 fix: bar colormap dynamic setting 2024-06-07 18:52:37 +02:00
bf699ec1fb fix: formatting isort 2024-06-07 18:40:42 +02:00
3094632134 feat(utils.colors): general color validators 2024-06-07 18:34:57 +02:00
6985ff0fce fix(curve): 2D scatter updated if color_map_z is changed 2024-06-07 16:28:51 +02:00
33f7be42c5 fix(curve): color_map_z setting works 2024-06-07 16:28:51 +02:00
semantic-release
36fac70361 0.57.7
Automatically generated by python-semantic-release
2024-06-07 14:15:17 +00:00
ca5e8d2fbb fix: add model_config to pydantic models to allow runtime checks after creation 2024-06-07 15:44:09 +02:00
828067f486 docs: added schema of BECDockArea and BECFigure 2024-06-06 20:14:47 +02:00
semantic-release
e5f4c0b952 0.57.6
Automatically generated by python-semantic-release
2024-06-06 18:05:05 +00:00
edb1775967 fix(bar): docstrings extended 2024-06-06 18:39:47 +02:00
semantic-release
d0d6908a74 0.57.5
Automatically generated by python-semantic-release
2024-06-06 16:01:55 +00:00
c037b87675 docs(figure): docs adjusted to be compatible with new signature 2024-06-06 17:54:41 +02:00
52bc322b2b refactor(figure): logic for .add_image and .image consolidated; logic for .add_plot and .plot consolidated 2024-06-06 17:54:41 +02:00
8479caf53a fix(waveform): added .plot method with the same signature as BECFigure.plot 2024-06-06 17:54:41 +02:00
82e2c898d2 fix(plot_base): .plot removed from plot_base.py, because there is no use case for it 2024-06-06 17:54:41 +02:00
semantic-release
4852076e4a 0.57.4
Automatically generated by python-semantic-release
2024-06-06 15:53:30 +00:00
15cbc21e5b fix(docks): set_title do update dock internal _name now 2024-06-06 16:01:27 +02:00
ffae5ee54e fix(docks): docks widget_list adn dockarea panels return values fixed 2024-06-06 16:01:27 +02:00
semantic-release
10d77f20d1 0.57.3
Automatically generated by python-semantic-release
2024-06-06 13:53:15 +00:00
4be0d14b74 docs(bar): docs updated 2024-06-06 13:56:10 +02:00
e883dbad81 fix(ring): automatic updates are disabled uf user specify updates manually with .set_update; 'scan_progres' do not reset number of rings 2024-06-06 13:56:10 +02:00
a2abad344f fix(ring): enable_auto_updates(True) do not reset properties of already setup bars 2024-06-06 13:56:10 +02:00
d44b1cf8b1 fix(ring): set_min_max accepts floats 2024-06-06 13:56:10 +02:00
c5b6499e41 fix(ring): set_update changed to Literals, no need to specify endpoint manually 2024-06-06 13:56:10 +02:00
a951ebf1be docs: fixed syntax of add_widget 2024-06-06 12:30:40 +02:00
32da803df9 docs: added auto update; closes #206 2024-06-06 12:26:34 +02:00
07d60cf735 docs: cleanup 2024-06-06 12:26:34 +02:00
semantic-release
fceb851c32 0.57.2
Automatically generated by python-semantic-release
2024-06-06 10:24:04 +00:00
e1af5ca60f fix(test/e2e): autoupdate e2e rewritten 2024-06-06 12:16:03 +02:00
7fb31fc4d7 fix(test/e2e): spiral_progress_bar e2e tests rewritten to use config_dict 2024-06-06 12:16:02 +02:00
5c6ba65469 fix(test/e2e): dockarea and dock e2e tests changed to check asserts against config_dict 2024-06-06 12:09:24 +02:00
cd9fc46ff8 fix: rpc_server_dock fixture now spawns the server process 2024-06-06 12:09:24 +02:00
2a88e17b23 fix: accept scalars or numpy arrays of 1 element 2024-06-06 12:09:24 +02:00
69f4371007 refactor: move _get_output and _start_plot_process at the module level 2024-06-06 12:09:24 +02:00
semantic-release
af2c816d35 0.57.1
Automatically generated by python-semantic-release
2024-06-06 07:31:09 +00:00
f51b25f0af fix: tests references to add_widget_bec refactored 2024-06-06 00:13:05 +02:00
c3f4845b4f docs: docs refactored from add_widget_bec to add_widget 2024-06-06 00:10:13 +02:00
8ae323f5c3 fix(dock): add_widget and add_widget_bec consolidated 2024-06-06 00:06:11 +02:00
semantic-release
a3805a765b 0.57.0
Automatically generated by python-semantic-release
2024-06-05 15:14:01 +00:00
8c03034acf feat(widgets/console): BECJupyterConsole added 2024-06-05 16:41:44 +02:00
4160f3d6d7 docs: extend user documentation for BEC Widgets 2024-06-05 16:30:29 +02:00
semantic-release
4742e1ff6a 0.56.3
Automatically generated by python-semantic-release
2024-06-05 14:26:41 +00:00
4af1abe4e1 ci: increased verbosity for e2e tests 2024-06-05 13:47:27 +02:00
131f49da8e fix: fixed support for auto updates 2024-06-05 13:47:27 +02:00
semantic-release
f9bf496bd3 0.56.2
Automatically generated by python-semantic-release
2024-06-05 10:41:50 +00:00
9648e3ea96 fix(bar): ring saves current value in config 2024-06-05 11:30:22 +02:00
4be756a867 fix(dock): dock saves configs of all children widgets 2024-06-05 11:07:14 +02:00
46face0ee5 fix(dock_area): save/restore state is saved in config 2024-06-05 11:01:31 +02:00
6f3b1ea985 fix(figure): added correct types of configs to subplot widgets 2024-06-05 10:47:50 +02:00
3c9181d93d docs: restructured docs layout 2024-06-04 12:22:02 +02:00
semantic-release
a05f24785e 0.56.1
Automatically generated by python-semantic-release
2024-06-04 09:13:07 +00:00
9d615c915c fix(spiral_progress_bar/rings): config min/max values added check for floats 2024-06-04 10:56:53 +02:00
d2539918b2 fix(spiral_progress_bar): Endpoint is always stored as a string in the RingConnection Config 2024-06-04 10:56:53 +02:00
semantic-release
ed264cb528 0.56.0
Automatically generated by python-semantic-release
2024-05-29 22:03:10 +00:00
ad208a5ef8 docs(examples): example apps section deleted 2024-05-29 21:34:19 +02:00
ddc9510c2b fix(examples): outdated examples removed (mca_plot.py, stream_plot.py, motor_example.py) 2024-05-29 20:45:21 +02:00
855be3551a ci: added tests for pyside6, pyqt6 and pyqt5, default test and e2e is python 3.11 and pyqt6 2024-05-29 18:56:53 +02:00
db301b1be2 build: added pyside6 as dependency 2024-05-29 14:36:42 +02:00
07b99d91a5 fix: compatibility adjustment to .ui loading and tests for PySide6 2024-05-29 14:36:42 +02:00
0fea8d6065 feat(utils/ui_loader): universal ui loader for pyside/pyqt 2024-05-28 10:59:31 +02:00
semantic-release
2a67f1667a 0.55.0
Automatically generated by python-semantic-release
2024-05-24 12:05:40 +00:00
76bd0d339a feat(widgets/progressbar): SpiralProgressBar added with rpc interface 2024-05-24 13:59:10 +02:00
semantic-release
43759082dd 0.54.0
Automatically generated by python-semantic-release
2024-05-24 09:05:54 +00:00
fc4d0f3bb2 feat(figure): changes to support direct plot functionality 2024-05-24 10:50:00 +02:00
a47a8ec413 build: added pyqt6 as sphinx build dependency 2024-05-23 11:43:48 +02:00
3455c60236 refactor(reconstruction): repository structure is changed to separate assets needed for each widget 2024-05-21 16:31:55 +02:00
edc25fbf9d refactor(clean-up): 1st generation widgets are removed 2024-05-21 16:31:55 +02:00
semantic-release
dc38f2308b 0.53.3
Automatically generated by python-semantic-release
2024-05-16 08:27:45 +00:00
7d64cac661 fix: removed apparently unnecessary sleep while waiting for an rpc response 2024-05-15 14:14:25 +02:00
semantic-release
ab4f1acd75 0.53.2
Automatically generated by python-semantic-release
2024-05-15 10:39:21 +00:00
9f8fbdd5fc fix: check device class without importing to speed up initial import time 2024-05-15 10:10:14 +02:00
d1e6cd388c fix: speed up initial import times using lazy import (from bec_lib) 2024-05-15 10:10:14 +02:00
5d09a13d88 fix: adapt to bec_lib changes (no more submodules in __init__.py) 2024-05-15 10:10:14 +02:00
0490e80c48 ci: added echo to highlight the current branch 2024-05-13 11:46:36 +02:00
semantic-release
48553ba9b1 0.53.1
Automatically generated by python-semantic-release
2024-05-09 18:58:27 +00:00
0f6a5e5fa9 fix: docs config 2024-05-09 20:51:26 +02:00
8ff36105d1 ci: fixed rtd pages url 2024-05-09 19:44:44 +02:00
semantic-release
ce78271af4 0.53.0
Automatically generated by python-semantic-release
2024-05-09 16:03:51 +00:00
57ee735e5c docs: update install instructions 2024-05-09 14:53:57 +02:00
32e1a9d847 fix: fixed semver job and upgraded to v9 2024-05-09 14:11:42 +02:00
5cc816d0af ci: use formatter config of toml file 2024-05-09 11:29:42 +02:00
4117fd7b5b refactor: applied formatter 2024-05-09 11:29:42 +02:00
c86ce302a9 feat: moved to pyproject.toml; closes #162 2024-05-09 11:21:18 +02:00
semantic-release
c33ce05951 0.52.1
Automatically generated by python-semantic-release
2024-05-08 13:56:39 +00:00
7f2f7cd07a fix(docstrings): docstrings formating fixed for sphinx to properly format readdocs 2024-05-08 15:31:22 +02:00
semantic-release
1454f6192b 0.52.0
Automatically generated by python-semantic-release
2024-05-07 14:37:46 +00:00
ceae979f37 fix(widgets/dock): BECDockArea close overwrites the default pyqtgraph Container close + minor improvements 2024-05-07 16:31:12 +02:00
fcd6ef0975 feat(utils/layout_manager): added GridLayoutManager to extend functionalities of native QGridLayout 2024-05-07 16:30:21 +02:00
d8ff8afcd4 feat(widget/dock): BECDock and BECDock area for dockable windows 2024-05-07 16:30:21 +02:00
03fa1f26d0 refactor(widget/plots): WidgetConfig changed to SubplotConfig 2024-05-07 16:30:21 +02:00
e65c7f3be8 ci: fixed support for child pipelines 2024-05-07 12:48:24 +02:00
semantic-release
20d6352351 0.51.0
Automatically generated by python-semantic-release
2024-05-07 10:13:25 +00:00
5ece269adb feat(utils): added plugin helper to find and load 2024-05-07 12:10:53 +02:00
e0851250ee ci: added rule for parent-child pipelines 2024-05-07 10:26:18 +02:00
799ea554de build(cli): changed repo name to bec_widgets 2024-05-06 16:00:46 +02:00
df323504fe build(setup): fakeredis added to dev env 2024-05-01 15:05:22 +02:00
0ab8aa3a2f build(setup): PyQt6 version is set to 6.7 2024-05-01 15:05:22 +02:00
semantic-release
dae8a3409a 0.50.2
Automatically generated by python-semantic-release
2024-04-30 16:08:47 +00:00
0dfcaa4b70 fix: 'disconnect_slot' has to be symmetric with 'connect_slot' regarding QtThreadSafeCallback 2024-04-30 17:27:02 +02:00
semantic-release
98cb2c08ea 0.50.1
Automatically generated by python-semantic-release
2024-04-29 15:58:30 +00:00
57cb136a09 fix(cli): BECFigure takes the port to connect to redis from the current BECClient, supporting plugins 2024-04-29 16:53:26 +02:00
semantic-release
1d84bca753 0.50.0
Automatically generated by python-semantic-release
2024-04-29 08:26:54 +00:00
4f261be4c7 test(cli/rpc_register): e2e RPCRegister 2024-04-28 18:54:20 +02:00
40eb75f85a test(cli/rpc_register): rpc_register tests added 2024-04-28 12:47:17 +02:00
13c018a797 fix(widgets/figure): access pattern changed for getting widgets by coordinates for rpc 2024-04-28 12:42:58 +02:00
8f20a0b3b1 fix(plots): cleanup policy reviewed for children items 2024-04-28 12:42:58 +02:00
6b6a6b2249 fix(rpc/client_utils): getoutput more transparent + error handling 2024-04-28 12:42:58 +02:00
2ca32675ec fix(rpc_register): thread lock for listign all connections 2024-04-28 12:42:58 +02:00
381d713837 feat(plots): universal cleanup and remove also for children items 2024-04-28 12:42:58 +02:00
a898e7e4f1 feat(rpc/rpc_register): singleton rpc register for all rpc connections for session 2024-04-28 12:42:58 +02:00
401 changed files with 27084 additions and 15398 deletions

View File

@@ -1,18 +1,20 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.10
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"
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
@@ -20,7 +22,13 @@ workflow:
include:
- template: Security/Secret-Detection.gitlab-ci.yml
- project: "bec/awi_utils"
file: "/templates/check-packages-job.yml"
inputs:
stage: test
path: "."
pytest_args: "-v --random-order tests/"
exclude_packages: ""
# different stages in the pipeline
stages:
@@ -30,19 +38,49 @@ stages:
- End2End
- Deploy
.install-qt-webengine-deps: &install-qt-webengine-deps
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- *install-qt-webengine-deps
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install black isort
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 ./
- isort --check --diff ./
- black --check --diff --color ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint:
stage: Formatter
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
- pip install -e .[dev,pyqt6]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
@@ -53,6 +91,8 @@ pylint:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
@@ -63,18 +103,21 @@ pylint-check:
- apt-get update
- apt-get install -y bc
script:
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse $CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $SOURCE_BRANCH_COMMIT_SHA $TARGET_BRANCH_COMMIT_SHA | grep '\.py$' || true);
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
- echo "Changed Python files:"
- $CHANGED_FILES
# Run pylint only on changed files
- mkdir ./pylint
- pylint $CHANGED_FILES --output-format=text . | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
- echo "Pylint score is $PYLINT_SCORE"
@@ -84,21 +127,20 @@ pylint-check:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
QT_QPA_PLATFORM: "offscreen"
script:
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- pip install -e ./bec/bec_lib[dev]
- pip install -e .[dev]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
@@ -108,19 +150,34 @@ tests:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
test-matrix:
parallel:
matrix:
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt6"
tests-3.11:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
allow_failure: true
tests-3.12:
extends: "tests"
stage: AdditionalTests
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.12
allow_failure: true
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
end-2-end-conda:
stage: End2End
@@ -130,29 +187,24 @@ end-2-end-conda:
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *clone-repos
- *install-os-packages
- conda config --prepend channels conda-forge
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.10
- conda create -q -n test-environment python=3.11
- conda init bash
- source ~/.bashrc
- conda activate test-environment
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev]
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest --start-servers --flush-redis --random-order
- pytest -v --start-servers --flush-redis --random-order
artifacts:
when: on_failure
@@ -164,10 +216,10 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
semver:
stage: Deploy
needs: ["tests"]
@@ -182,20 +234,19 @@ semver:
- git fetch --tags
- git tag
# build
- pip install python-semantic-release==7.* wheel
# build and publish package
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- export REPOSITORY_USERNAME=__token__
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
- >
semantic-release publish -v DEBUG
-D version_variable=./setup.py:__version__
-D hvcs=gitlab
-D branch=main
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
- semantic-release publish
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "main"'
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
pages:
stage: Deploy
@@ -203,9 +254,9 @@ pages:
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: '$CI_COMMIT_TAG != null'
- if: "$CI_COMMIT_TAG != null"
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main"'
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/

View File

@@ -3,7 +3,7 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=PyQt5, pyqtgraph
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may

File diff suppressed because it is too large Load Diff

View File

@@ -6,32 +6,33 @@ BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Expe
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash
pip install bec-widgets
pip install bec_widgets PyQt6
```
For development purposes, you can clone the repository and install the package locally in editable mode:
```bash
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec-widgets
pip install -e .[dev]
cd bec_widgets
pip install -e .[dev,pyqt6]
```
BEC Widgets currently supports both PyQt5 and PyQt6. By default, PyQt6 is installed.
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
Python Qt distributions manually.
To select a specific Python Qt distribution, install the package with an additional tag:
```bash
pip install bec-widgets[pyqt6]
pip install bec_widgets[pyqt6]
```
or
```bash
pip install bec-widgets[pyqt5]
pip install bec_widgets[pyside6]
```
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://beamline-experiment-control.readthedocs.io/en/latest/).
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## Contributing

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 718 B

View File

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

After

Width:  |  Height:  |  Size: 721 B

View File

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

After

Width:  |  Height:  |  Size: 559 B

View File

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

After

Width:  |  Height:  |  Size: 604 B

View File

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

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -1,2 +1 @@
from .auto_updates import AutoUpdates, ScanInfo
from .client import BECFigure
from .client import *

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import threading
from queue import Queue
from typing import TYPE_CHECKING
from pydantic import BaseModel
if TYPE_CHECKING:
from .client import BECFigure
from .client import BECDockArea, BECFigure
class ScanInfo(BaseModel):
@@ -15,12 +17,35 @@ class ScanInfo(BaseModel):
scan_report_devices: list
monitored_devices: list
status: str
model_config: dict = {"validate_assignment": True}
class AutoUpdates:
def __init__(self, figure: BECFigure, enabled: bool = True):
self.enabled = enabled
self.figure = figure
create_default_dock: bool = False
enabled: bool = False
dock_name: str = None
def __init__(self, gui: BECDockArea):
self.gui = gui
self.msg_queue = Queue()
self.auto_update_thread = None
self._shutdown_sentinel = object()
self.start()
def start(self):
"""
Start the auto update thread.
"""
self.auto_update_thread = threading.Thread(target=self.process_queue)
self.auto_update_thread.start()
def start_default_dock(self):
"""
Create a default dock for the auto updates.
"""
dock = self.gui.add_dock("default_figure")
dock.add_widget("BECFigure")
self.dock_name = "default_figure"
@staticmethod
def get_scan_info(msg) -> ScanInfo:
@@ -44,6 +69,18 @@ class AutoUpdates:
status=status,
)
def get_default_figure(self) -> BECFigure | None:
"""
Get the default figure from the GUI.
"""
dock = self.gui.panels.get(self.dock_name, [])
if not dock:
return None
widgets = dock.widget_list
if not widgets:
return None
return widgets[0]
def run(self, msg):
"""
Run the update function if enabled.
@@ -55,6 +92,16 @@ class AutoUpdates:
info = self.get_scan_info(msg)
self.handler(info)
def process_queue(self):
"""
Process the message queue.
"""
while True:
msg = self.msg_queue.get()
if msg is self._shutdown_sentinel:
break
self.run(msg)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
"""
@@ -86,33 +133,52 @@ class AutoUpdates:
"""
Simple line scan.
"""
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
if not dev_y:
return
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y)
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def simple_grid_scan(self, info: ScanInfo) -> None:
"""
Simple grid scan.
"""
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y, dev_z, label=f"Scan {info.scan_number}")
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
fig.clear_all()
plt = fig.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None:
"""
Best effort scan.
"""
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.figure.selected_device)
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
if not dev_y:
return
self.figure.clear_all()
plt = self.figure.plot(dev_x, dev_y, label=f"Scan {info.scan_number}")
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def shutdown(self):
"""
Shutdown the auto update thread.
"""
self.msg_queue.put(self._shutdown_sentinel)
if self.auto_update_thread:
self.auto_update_thread.join()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -2,27 +2,33 @@ from __future__ import annotations
import importlib
import importlib.metadata as imd
import json
import os
import select
import subprocess
import sys
import threading
import time
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from bec_lib import MessageEndpoints, messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from qtpy.QtCore import QCoreApplication
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
from bec_widgets.utils.bec_dispatcher import BECDispatcher
if TYPE_CHECKING:
from bec_widgets.cli.client import BECFigure
from bec_lib.device import DeviceBase
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
def rpc_call(func):
@@ -56,20 +62,91 @@ def rpc_call(func):
return wrapper
class BECFigureClientMixin:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)
os.set_blocking(process.stderr.fileno(), False)
while process.poll() is None:
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
for stream in (process.stdout, process.stderr):
buf = stream_buffer[stream]
if stream in readylist:
buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n")
if output:
log_func[stream](output)
buf.clear()
buf.append(remaining)
except Exception as e:
print(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
"""
Start the plot in a new process.
Logger must be a logger object with "debug" and "error" functions,
or it can be left to "None" as default. None means output from the
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
if config:
if isinstance(config, dict):
config = json.dumps(config)
command.extend(["--config", config])
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
else:
stdout_redirect = subprocess.PIPE
stderr_redirect = subprocess.PIPE
process = subprocess.Popen(
command,
text=True,
start_new_session=True,
stdout=stdout_redirect,
stderr=stderr_redirect,
env=env_dict,
)
if logger is None:
process_output_processing_thread = None
else:
process_output_processing_thread = threading.Thread(
target=_get_output, args=(process, logger)
)
process_output_processing_thread.start()
return process, process_output_processing_thread
class BECGuiClientMixin:
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._process = None
self.update_script = self._get_update_script()
self._process_output_processing_thread = None
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
self.stderr_output = []
def _get_update_script(self) -> AutoUpdates:
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
return ep.load()(figure=self)
try:
spec = importlib.util.find_spec(ep.module)
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self)
except Exception as e:
print(f"Error loading auto update script from plugin: {str(e)}")
return None
@property
@@ -81,7 +158,7 @@ class BECFigureClientMixin:
@selected_device.setter
def selected_device(self, device: str | DeviceBase):
if isinstance(device, DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._selected_device = device.name
elif isinstance(device, str):
self._selected_device = device
@@ -94,8 +171,8 @@ class BECFigureClientMixin:
)
@staticmethod
def _handle_msg_update(msg: MessageObject, parent: BECFigureClientMixin) -> None:
if parent.update_script is not None:
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
if parent.auto_updates is not None:
# pylint: disable=protected-access
parent._update_script_msg_parser(msg.value)
@@ -103,71 +180,37 @@ class BECFigureClientMixin:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
self.update_script.run(msg)
self.auto_updates.msg_queue.put(msg)
def show(self) -> None:
"""
Show the figure.
"""
if self._process is None or self._process.poll() is not None:
self._start_plot_process()
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
def close(self) -> None:
"""
Close the figure.
Close the gui window.
"""
if self._process is None:
return
if self.gui_is_alive():
self._run_rpc("close", (), wait_for_rpc_response=True)
else:
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
self._client.shutdown()
def _start_plot_process(self) -> None:
"""
Start the plot in a new process.
"""
self._start_update_script()
# pylint: disable=subprocess-run-check
monitor_module = importlib.import_module("bec_widgets.cli.server")
monitor_path = monitor_module.__file__
command = [sys.executable, "-u", monitor_path, "--id", self._gui_id]
self._process = subprocess.Popen(
command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
self._process_output_processing_thread = threading.Thread(target=self._get_output)
self._process_output_processing_thread.start()
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print("".join(self.stderr_output))
# Flush list
self.stderr_output.clear()
def _get_output(self) -> str:
os.set_blocking(self._process.stdout.fileno(), False)
os.set_blocking(self._process.stderr.fileno(), False)
while self._process.poll() is None:
readylist, _, _ = select.select([self._process.stdout, self._process.stderr], [], [], 1)
if self._process.stdout in readylist:
# print("*"*10, self._process.stdout.read(1024), flush=True, end="")
self._process.stdout.read(1024)
if self._process.stderr in readylist:
# print("!"*10, self._process.stderr.read(1024), flush=True, end="", file=sys.stderr)
print(self._process.stderr.read(1024), flush=True, end="", file=sys.stderr)
self.stderr_output.append(self._process.stderr.read(1024))
if self._process:
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
if self.auto_updates is not None:
self.auto_updates.shutdown()
class RPCResponseTimeoutError(Exception):
@@ -181,10 +224,12 @@ class RPCResponseTimeoutError(Exception):
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECDispatcher().client
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
super().__init__()
# print(f"RPCBase: {self._gui_id}")
@@ -205,7 +250,7 @@ class RPCBase:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
"""
Run the RPC call.
@@ -227,16 +272,39 @@ class RPCBase:
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if not wait_for_rpc_response:
return None
response = self._wait_for_response(request_id)
# get class name
if not response.content["accepted"]:
raise ValueError(response.content["message"]["error"])
msg_result = response.content["message"].get("result")
return self._create_widget_from_msg_result(msg_result)
if wait_for_rpc_response:
try:
finished = self._msg_wait_event.wait(10)
if not finished:
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -259,33 +327,13 @@ class RPCBase:
return cls(parent=self, **msg_result)
return msg_result
def _wait_for_response(self, request_id: str, timeout: int = 5):
"""
Wait for the response from the server.
Args:
request_id(str): The request ID.
timeout(int): The timeout in seconds.
Returns:
The response from the server.
"""
start_time = time.time()
response = None
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
time.sleep(0.1)
if response is None and (time.time() - start_time) >= timeout:
raise RPCResponseTimeoutError(request_id, timeout)
return response
def gui_is_alive(self):
"""
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
return heart is not None
if heart is None:
return False
if heart.status == messages.BECStatus.RUNNING:
return True
return False

View File

@@ -1,10 +1,16 @@
# pylint: disable=missing-module-docstring
from __future__ import annotations
import argparse
import inspect
import os
import sys
import black
import isort
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -14,51 +20,75 @@ else:
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
)
def get_overloads(obj):
# Dummy function for Python versions before 3.11
def get_overloads(_obj):
"""
Dummy function for Python versions before 3.11.
"""
return []
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from bec_widgets.cli.client_utils import rpc_call, RPCBase, BECFigureClientMixin
from typing import Literal, Optional, overload"""
import enum
from typing import Literal, Optional, overload
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
# pylint: skip-file"""
self.content = ""
def generate_client(self, published_classes: list):
def generate_client(self, class_container: BECClassContainer):
"""
Generate the client for the published classes.
Args:
published_classes(list): The list of published classes (e.g. [BECWaveform1D, BECFigure]).
class_container: The class container with the classes to generate the client for.
"""
for cls in published_classes:
rpc_top_level_classes = class_container.rpc_top_level_classes
rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = class_container.connector_classes
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
self.content += "\n\n"
self.generate_content_for_class(cls)
def write_client_enum(self, published_classes: list[type]):
"""
Write the client enum to the content.
"""
self.content += """
class Widgets(str, enum.Enum):
\"\"\"
Enum for the available widgets.
\"\"\"
"""
for cls in published_classes:
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
def generate_content_for_class(self, cls):
"""
Generate the content for the class.
Args:
cls: The class for which to generate the content.
"""
class_name = cls.__name__
module = cls.__module__
# Generate the header
# self.header += f"""
# from {module} import {class_name}"""
# Generate the content
if cls.__name__ == "BECFigure":
if cls.__name__ == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase, BECFigureClientMixin):"""
class {class_name}(RPCBase, BECGuiClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
@@ -100,31 +130,60 @@ class {class_name}(RPCBase):"""
except black.NothingChanged:
formatted_content = full_content
isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=True,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
def main():
"""
Main entry point for the script, controlled by command line arguments.
"""
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
args = parser.parse_args()
if args.core:
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets")
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
for cls in rpc_classes.plugins:
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
# if the class directory already has a register, plugin and pyproject file, skip
if os.path.exists(
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
):
continue
plugin.run()
if __name__ == "__main__": # pragma: no cover
import os
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECImageShow, BECMotorMap, BECPlotBase, BECWaveform
from bec_widgets.widgets.plots.image import BECImageItem
from bec_widgets.widgets.plots.waveform import BECCurve
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
clss = [
BECPlotBase,
BECWaveform,
BECFigure,
BECCurve,
BECImageShow,
BECConnector,
BECImageItem,
BECMotorMap,
]
generator = ClientGenerator()
generator.generate_client(clss)
generator.write(client_path)
sys.argv = ["generate_cli.py", "--core"]
main()

View File

@@ -0,0 +1,80 @@
from threading import Lock
from weakref import WeakValueDictionary
from qtpy.QtCore import QObject
class RPCRegister:
"""
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
"""
_instance = None
_initialized = False
_lock = Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(RPCRegister, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._rpc_register = WeakValueDictionary()
self._initialized = True
def add_rpc(self, rpc: QObject):
"""
Add an RPC object to the register.
Args:
rpc(QObject): The RPC object to be added to the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError("RPC object must have a 'gui_id' attribute.")
self._rpc_register[rpc.gui_id] = rpc
def remove_rpc(self, rpc: str):
"""
Remove an RPC object from the register.
Args:
rpc(str): The RPC object to be removed from the register.
"""
if not hasattr(rpc, "gui_id"):
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject:
"""
Get an RPC object by its ID.
Args:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject: The RPC object with the given ID.
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = dict(self._rpc_register)
return connections
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False

View File

@@ -0,0 +1,53 @@
from bec_widgets.utils import BECConnector
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
def __init__(self):
self._widget_classes = None
@property
def widget_classes(self):
"""
Get the available widget classes.
Returns:
dict: The available widget classes.
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_rpc_classes
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECConnector): The created widget.
"""
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()

View File

@@ -1,43 +1,66 @@
import inspect
import threading
import time
from __future__ import annotations
from bec_lib import MessageEndpoints, messages
import inspect
import json
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import Union
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.plots import BECCurve, BECImageShow, BECWaveform
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
class BECWidgetsCLIServer:
WIDGETS = [BECWaveform, BECFigure, BECCurve, BECImageShow]
def __init__(self, gui_id: str = None, dispatcher: BECDispatcher = None, client=None) -> None:
self.dispatcher = BECDispatcher() if dispatcher is None else dispatcher
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.fig = BECFigure(gui_id=self.gui_id)
self.gui = gui_class(gui_id=self.gui_id)
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(1000) # Emit heartbeat every 1 seconds
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
obj = self.get_object_from_config(msg["parameter"])
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
@@ -54,20 +77,10 @@ class BECWidgetsCLIServer:
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
# check if the object is the figure
if gui_id == self.fig.gui_id:
return self.fig
# check if the object is a widget
if gui_id in self.fig._widgets:
obj = self.fig._widgets[config["gui_id"]]
return obj
if self.fig._widgets:
for widget in self.fig._widgets.values():
item = widget.find_widget_by_id(gui_id)
if item:
return item
raise ValueError(f"Object with gui_id {gui_id} not found")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
method_obj = getattr(obj, method)
@@ -100,48 +113,125 @@ class BECWidgetsCLIServer:
return obj
def emit_heartbeat(self):
if self._shutdown_event is False:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=10,
)
print("Heartbeat emitted")
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
def shutdown(self):
self._shutdown_event = True
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
self.gui.close()
self.client.shutdown()
if __name__ == "__main__": # pragma: no cover
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
def write(self, buffer):
self._buffer.append(buffer)
def flush(self):
lines, _, remaining = "".join(self._buffer).rpartition("\n")
self._log_func(lines)
self._buffer = [remaining]
def close(self):
return
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
if config:
try:
config = json.loads(config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# if no config is provided, use the default config
service_config = ServiceConfig()
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
return server
def main():
import argparse
import sys
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
icon = QIcon()
icon.addFile("bec_widgets_icon.png", size=QSize(48, 48))
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
import bec_widgets
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
"--gui_class",
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument("--config", type=str, help="Config file or config string.")
args = parser.parse_args()
server = BECWidgetsCLIServer(gui_id=args.id)
# server = BECWidgetsCLIServer(gui_id="test")
if args.gui_class == "BECFigure":
gui_class = BECFigure
elif args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECFigure
fig = server.fig
win.setCentralWidget(fig)
win.show()
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
app.aboutToQuit.connect(server.shutdown)
sys.exit(app.exec())
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
server = _start_server(args.id, gui_class, args.config)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(app.exec())
if __name__ == "__main__": # pragma: no cover
sys.argv = ["bec_widgets.cli.server", "--id", "test", "--gui_class", "BECDockArea"]
main()

View File

@@ -1,9 +0,0 @@
from .motor_movement import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -1,307 +0,0 @@
import json
import os
import threading
import h5py
import numpy as np
import pyqtgraph as pg
import zmq
from pyqtgraph.Qt import uic
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import QDialog, QFileDialog, QFrame, QLabel, QShortcut, QVBoxLayout, QWidget
# from scipy.stats import multivariate_normal
class EigerPlot(QWidget):
update_signal = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
# pg.setConfigOptions(background="w", foreground="k", antialias=True)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "eiger_plot.ui"), self)
# Set widow name
self.setWindowTitle("Eiger Plot")
self.hist_lims = None
self.mask = None
self.image = None
# UI
self.init_ui()
self.hook_signals()
self.key_bindings()
# ZMQ Consumer
self._zmq_consumer_exit_event = threading.Event()
self._zmq_consumer_thread = self.start_zmq_consumer()
def close(self):
super().close()
self._zmq_consumer_exit_event.set()
self._zmq_consumer_thread.join()
def init_ui(self):
# Create Plot and add ImageItem
self.plot_item = pg.PlotItem()
self.plot_item.setAspectLocked(True)
self.imageItem = pg.ImageItem()
self.plot_item.addItem(self.imageItem)
# Setting up histogram
self.hist = pg.HistogramLUTItem()
self.hist.setImageItem(self.imageItem)
self.hist.gradient.loadPreset("magma")
self.update_hist()
# Adding Items to Graphical Layout
self.glw.addItem(self.plot_item)
self.glw.addItem(self.hist)
def hook_signals(self):
# Buttons
# self.pushButton_test.clicked.connect(self.start_sim_stream)
self.pushButton_mask.clicked.connect(self.load_mask_dialog)
self.pushButton_delete_mask.clicked.connect(self.delete_mask)
self.pushButton_help.clicked.connect(self.show_help_dialog)
# SpinBoxes
self.doubleSpinBox_hist_min.valueChanged.connect(self.update_hist)
self.doubleSpinBox_hist_max.valueChanged.connect(self.update_hist)
# Signal/Slots
self.update_signal.connect(self.on_image_update)
def key_bindings(self):
# Key bindings for rotation
rotate_plus = QShortcut(QKeySequence("Ctrl+A"), self)
rotate_minus = QShortcut(QKeySequence("Ctrl+Z"), self)
self.comboBox_rotation.setToolTip("Increase rotation: Ctrl+A\nDecrease rotation: Ctrl+Z")
self.checkBox_transpose.setToolTip("Toggle transpose: Ctrl+T")
max_index = self.comboBox_rotation.count() - 1 # Maximum valid index
rotate_plus.activated.connect(
lambda: self.comboBox_rotation.setCurrentIndex(
min(self.comboBox_rotation.currentIndex() + 1, max_index)
)
)
rotate_minus.activated.connect(
lambda: self.comboBox_rotation.setCurrentIndex(
max(self.comboBox_rotation.currentIndex() - 1, 0)
)
)
# Key bindings for transpose
transpose = QShortcut(QKeySequence("Ctrl+T"), self)
transpose.activated.connect(self.checkBox_transpose.toggle)
FFT = QShortcut(QKeySequence("Ctrl+F"), self)
FFT.activated.connect(self.checkBox_FFT.toggle)
self.checkBox_FFT.setToolTip("Toggle FFT: Ctrl+F")
log = QShortcut(QKeySequence("Ctrl+L"), self)
log.activated.connect(self.checkBox_log.toggle)
self.checkBox_log.setToolTip("Toggle log: Ctrl+L")
mask = QShortcut(QKeySequence("Ctrl+M"), self)
mask.activated.connect(self.pushButton_mask.click)
self.pushButton_mask.setToolTip("Load mask: Ctrl+M")
delete_mask = QShortcut(QKeySequence("Ctrl+D"), self)
delete_mask.activated.connect(self.pushButton_delete_mask.click)
self.pushButton_delete_mask.setToolTip("Delete mask: Ctrl+D")
def update_hist(self):
self.hist_levels = [
self.doubleSpinBox_hist_min.value(),
self.doubleSpinBox_hist_max.value(),
]
self.hist.setLevels(min=self.hist_levels[0], max=self.hist_levels[1])
self.hist.setHistogramRange(
self.hist_levels[0] - 0.1 * self.hist_levels[0],
self.hist_levels[1] + 0.1 * self.hist_levels[1],
)
def load_mask_dialog(self):
options = QFileDialog.Options()
options |= QFileDialog.ReadOnly
file_name, _ = QFileDialog.getOpenFileName(
self, "Select Mask File", "", "H5 Files (*.h5);;All Files (*)", options=options
)
if file_name:
self.load_mask(file_name)
def load_mask(self, path):
try:
with h5py.File(path, "r") as f:
self.mask = f["data"][...]
if self.mask is not None:
# Set label to mask name without path
self.label_mask.setText(os.path.basename(path))
except KeyError as e:
# Update GUI with the error message
print(f"Error: {str(e)}")
def delete_mask(self):
self.mask = None
self.label_mask.setText("No Mask")
@pyqtSlot()
def on_image_update(self):
# TODO first rotate then transpose
if self.mask is not None:
# self.image = np.ma.masked_array(self.image, mask=self.mask) #TODO test if np works
self.image = self.image * (1 - self.mask) + 1
if self.checkBox_FFT.isChecked():
self.image = np.abs(np.fft.fftshift(np.fft.fft2(self.image)))
if self.comboBox_rotation.currentIndex() > 0: # rotate
self.image = np.rot90(self.image, k=self.comboBox_rotation.currentIndex(), axes=(0, 1))
if self.checkBox_transpose.isChecked(): # transpose
self.image = np.transpose(self.image)
if self.checkBox_log.isChecked():
self.image = np.log10(self.image)
self.imageItem.setImage(self.image, autoLevels=False)
###############################
# ZMQ Consumer
###############################
def start_zmq_consumer(self):
consumer_thread = threading.Thread(
target=self.zmq_consumer, args=(self._zmq_consumer_exit_event,), daemon=True
)
consumer_thread.start()
return consumer_thread
def zmq_consumer(self, exit_event):
print("starting consumer")
live_stream_url = "tcp://129.129.95.38:20000"
receiver = zmq.Context().socket(zmq.SUB)
receiver.connect(live_stream_url)
receiver.setsockopt_string(zmq.SUBSCRIBE, "")
poller = zmq.Poller()
poller.register(receiver, zmq.POLLIN)
# code could be a bit simpler here, testing exit_event in
# 'while' condition, but like this it is easier for the
# 'test_zmq_consumer' test
while True:
if poller.poll(1000): # 1s timeout
raw_meta, raw_data = receiver.recv_multipart(zmq.NOBLOCK)
meta = json.loads(raw_meta.decode("utf-8"))
self.image = np.frombuffer(raw_data, dtype=meta["type"]).reshape(meta["shape"])
self.update_signal.emit()
if exit_event.is_set():
break
receiver.disconnect(live_stream_url)
###############################
# just simulations from here
###############################
def show_help_dialog(self):
dialog = QDialog(self)
dialog.setWindowTitle("Help")
layout = QVBoxLayout()
# Key bindings section
layout.addWidget(QLabel("Keyboard Shortcuts:"))
key_bindings = [
("Ctrl+A", "Increase rotation"),
("Ctrl+Z", "Decrease rotation"),
("Ctrl+T", "Toggle transpose"),
("Ctrl+F", "Toggle FFT"),
("Ctrl+L", "Toggle log scale"),
("Ctrl+M", "Load mask"),
("Ctrl+D", "Delete mask"),
]
for keys, action in key_bindings:
layout.addWidget(QLabel(f"{keys} - {action}"))
# Separator
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(separator)
# Histogram section
layout.addWidget(QLabel("Histogram:"))
layout.addWidget(
QLabel(
"Use the Double Spin Boxes to adjust the minimum and maximum values of the histogram."
)
)
# Another Separator
another_separator = QFrame()
another_separator.setFrameShape(QFrame.HLine)
another_separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(another_separator)
# Mask section
layout.addWidget(QLabel("Mask:"))
layout.addWidget(
QLabel(
"Use 'Load Mask' to load a mask from an H5 file. 'Delete Mask' removes the current mask."
)
)
dialog.setLayout(layout)
dialog.exec()
###############################
# just simulations from here
###############################
# def start_sim_stream(self):
# sim_stream_thread = threading.Thread(target=self.sim_stream, daemon=True)
# sim_stream_thread.start()
#
# def sim_stream(self):
# for i in range(100):
# # Generate 100x100 image of random noise
# self.image = np.random.rand(100, 100) * 0.2
#
# # Define Gaussian parameters
# x, y = np.mgrid[0:50, 0:50]
# pos = np.dstack((x, y))
#
# # Center at (25, 25) longer along y-axis
# rv = multivariate_normal(mean=[25, 25], cov=[[25, 0], [0, 80]])
#
# # Generate Gaussian in the first quadrant
# gaussian_quadrant = rv.pdf(pos) * 40
#
# # Place Gaussian in the first quadrant
# self.image[0:50, 0:50] += gaussian_quadrant * 10
#
# self.update_signal.emit()
# time.sleep(0.1)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
plot = EigerPlot()
plot.show()
sys.exit(app.exec())

View File

@@ -1,207 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>874</width>
<height>762</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Plot Control</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Histogram MIN</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_min">
<property name="minimum">
<double>-100000.000000000000000</double>
</property>
<property name="maximum">
<double>100000.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Histogram MAX</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="doubleSpinBox_hist_max">
<property name="minimum">
<double>-100000.000000000000000</double>
</property>
<property name="maximum">
<double>100000.000000000000000</double>
</property>
<property name="value">
<double>2.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Data Control</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_FFT">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>FFT</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_log">
<property name="text">
<string>log</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_mask">
<property name="text">
<string>Load Mask</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_delete_mask">
<property name="text">
<string>Delete Mask</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Orientation</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="2" column="1">
<widget class="QComboBox" name="comboBox_rotation">
<item>
<property name="text">
<string>0</string>
</property>
</item>
<item>
<property name="text">
<string>90</string>
</property>
</item>
<item>
<property name="text">
<string>180</string>
</property>
</item>
<item>
<property name="text">
<string>270</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Rotation</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="2">
<widget class="QCheckBox" name="checkBox_transpose">
<property name="text">
<string>Transpose</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_4">
<property name="title">
<string>Help</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_mask">
<property name="text">
<string>No Mask</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_help">
<property name="text">
<string>Help</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="GraphicsLayoutWidget" name="glw"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,92 @@
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.ui_loader import UILoader
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECGeneralApp(QMainWindow):
def __init__(self, parent=None):
super(BECGeneralApp, self).__init__(parent)
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
self.load_ui(ui_file_path)
self.resize(1280, 720)
self.ini_ui()
def ini_ui(self):
self._setup_icons()
self._hook_menubar_docs()
self._hook_theme_bar()
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def _hook_menubar_docs(self):
# BEC Docs
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
# BEC Widgets Docs
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
# Bug report
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
def change_theme(self, theme):
apply_theme(theme)
def _setup_icons(self):
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
computer_icon = QIcon.fromTheme("computer")
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
self.ui.action_BEC_docs.setIcon(help_icon)
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
self.ui.action_bug_report.setIcon(bug_icon)
self.ui.central_tab.setTabIcon(0, widget_icon)
self.ui.central_tab.setTabIcon(1, computer_icon)
def _hook_theme_bar(self):
self.ui.action_light.setCheckable(True)
self.ui.action_dark.setCheckable(True)
# Create an action group to make sure only one can be checked at a time
theme_group = QActionGroup(self)
theme_group.addAction(self.ui.action_light)
theme_group.addAction(self.ui.action_dark)
theme_group.setExclusive(True)
# Connect the actions to the theme change method
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
self.ui.action_dark.trigger()
def main(): # pragma: no cover
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-Dark.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
main_window = BECGeneralApp()
main_window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,262 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>1139</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTabWidget" name="central_tab">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="dock_area_tab">
<attribute name="title">
<string>Dock Area</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="vscode_tab">
<attribute name="icon">
<iconset theme="QIcon::ThemeIcon::Computer"/>
</attribute>
<attribute name="title">
<string>Visual Studio Code</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="VSCodeEditor" name="vscode"/>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>31</height>
</rect>
</property>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="action_BEC_docs"/>
<addaction name="action_BEC_widgets_docs"/>
<addaction name="action_bug_report"/>
</widget>
<widget class="QMenu" name="menuTheme">
<property name="title">
<string>Theme</string>
</property>
<addaction name="action_light"/>
<addaction name="action_dark"/>
</widget>
<addaction name="menuTheme"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="dock_scan_control">
<property name="windowTitle">
<string>Scan Control</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="ScanControl" name="scan_control"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_status_2">
<property name="windowTitle">
<string>BEC Service Status</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_3">
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECStatusBox" name="bec_status_box_2"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_queue">
<property name="windowTitle">
<string>Scan Queue</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_4">
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECQueue" name="bec_queue">
<row/>
<column/>
<column/>
<column/>
<item row="0" column="0"/>
<item row="0" column="1"/>
<item row="0" column="2"/>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="action_BEC_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Docs</string>
</property>
</action>
<action name="action_BEC_widgets_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Widgets Docs</string>
</property>
</action>
<action name="action_bug_report">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogError"/>
</property>
<property name="text">
<string>Bug Report</string>
</property>
</action>
<action name="action_light">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Light</string>
</property>
</action>
<action name="action_dark">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Dark</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWebEngineView</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QTableWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>VSCodeEditor</class>
<extends>WebsiteWidget</extends>
<header>vs_code_editor</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>QWebEngineView</class>
<extends></extends>
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,15 @@
import webbrowser
class BECWebLinksMixin:
@staticmethod
def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")

View File

@@ -2,30 +2,23 @@ import os
import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import uic
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
QHBoxLayout,
QSplitter,
QTabWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import BECDispatcher
from bec_widgets.widgets import BECFigure
class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
def __init__(self):
super().__init__()
self.kernel_manager = QtInProcessKernelManager()
self.kernel_manager.start_kernel(show_banner=False)
self.kernel_client = self.kernel_manager.client()
self.kernel_client.start_channels()
self.kernel_manager.kernel.shell.push({"np": np, "pg": pg})
# self.set_console_font_size(70)
def shutdown_kernel(self):
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel()
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -34,69 +27,191 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "jupyter_console_window.ui"), self)
self._init_ui()
self.splitter.setSizes([200, 100])
self.safe_close = False
# self.figure.clean_signal.connect(self.confirm_close)
# console push
self.console.kernel_manager.kernel.shell.push(
{
"fig": self.figure,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"bec": self.figure.client,
"scans": self.figure.client.scans,
"dev": self.figure.client.device_manager.devices,
}
)
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"pg": pg,
"fig": self.figure,
"dock": self.dock,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"w4": self.w4,
"w5": self.w5,
"w6": self.w6,
"w7": self.w7,
"w8": self.w8,
"w9": self.w9,
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
# "bar": self.bar,
# "cm": self.colormap,
"im": self.im,
"mm": self.mm,
}
)
def _init_ui(self):
# Plotting window
self.glw_1_layout = QVBoxLayout(self.glw) # Create a new QVBoxLayout
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
self.layout = QHBoxLayout(self)
# Horizontal splitter
splitter = QSplitter(self)
self.layout.addWidget(splitter)
tab_widget = QTabWidget(splitter)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
second_tab = QWidget()
second_tab_layout = QVBoxLayout(second_tab)
self.figure = BECFigure(parent=self, gui_id="figure")
second_tab_layout.addWidget(self.figure)
tab_widget.addTab(second_tab, "BEC Figure")
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
# add stuff to figure
self._init_figure()
self.console_layout = QVBoxLayout(self.widget_console)
self.console = JupyterConsoleWidget()
self.console_layout.addWidget(self.console)
self.console.set_default_style("linux")
# init dock for testing
self._init_dock()
self.setWindowTitle("Jupyter Console Window")
def _init_figure(self):
self.figure.plot("samx", "bpm4d")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
self.w1 = self.figure.plot(
x_name="samx",
y_name="bpm4i",
# title="Standard Plot with sync device, custom labels - w1",
# x_label="Motor Position",
# y_label="Intensity (A.U.)",
row=0,
col=0,
)
self.w1.set(
title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position",
y_label="Intensity (A.U.)",
)
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
self.w3 = self.figure.image(
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
)
self.w4 = self.figure.plot(
x_name="samx",
y_name="samy",
z_name="bpm4i",
color_map_z="magma",
new=True,
title="2D scatter plot - w4",
row=0,
col=3,
)
self.w5 = self.figure.plot(
y_name="bpm4i",
new=True,
title="Best Effort Plot - w5",
dap="GaussianModel",
row=1,
col=0,
)
self.w6 = self.figure.plot(
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
)
self.w7 = self.figure.plot(
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
)
self.w8 = self.figure.plot(
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
)
self.w9 = self.figure.plot(
x_name="timestamp",
y_name="monitor_async",
new=True,
title="Async Plot - timestamp - w9",
row=2,
col=1,
)
self.w10 = self.figure.plot(
x_name="index",
y_name="monitor_async",
new=True,
title="Async Plot - index - w10",
row=2,
col=2,
)
self.figure.change_layout(2, 2)
def _init_dock(self):
self.w1 = self.figure[0, 0]
self.w2 = self.figure[0, 1]
self.w3 = self.figure[1, 0]
self.d0 = self.dock.add_dock(name="dock_0")
self.mm = self.d0.add_widget("BECMotorMapWidget")
self.mm.change_motors("samx", "samy")
# curves for w1
self.w1.add_curve_scan("samx", "samy", "bpm4i", pen_style="dash")
self.w1.add_curve_scan("samx", "samy", "bpm3a", pen_style="dash")
self.c1 = self.w1.get_config()
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("eiger")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
self.wf.plot(x_name="samx", y_name="bpm3a")
self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
# self.bar.set_diameter(200)
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
# self.colormap = pg.GradientWidget()
# self.d3.add_widget(self.colormap, row=0, col=0)
self.dock.save_state()
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.cleanup()
self.dock.close()
self.figure.cleanup()
self.figure.close()
self.console.close()
super().closeEvent(event)
if __name__ == "__main__": # pragma: no cover
import sys
import bec_widgets
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark")
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "terminal_icon.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
win = JupyterConsoleWindow()
win.show()
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>2104</width>
<height>966</height>
</rect>
</property>
<property name="windowTitle">
<string>Plotting Console</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="glw" native="true"/>
<widget class="QWidget" name="widget_console" native="true"/>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,158 +0,0 @@
# import simulation_progress as SP
import numpy as np
import pyqtgraph as pg
from bec_lib import MessageEndpoints, messages
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
class StreamApp(QWidget):
update_signal = pyqtSignal()
new_scan_id = pyqtSignal(str)
def __init__(self, device, sub_device):
super().__init__()
pg.setConfigOptions(background="w", foreground="k")
self.init_ui()
self.setWindowTitle("MCA readout")
self.data = None
self.scan_id = None
self.stream_consumer = None
self.device = device
self.sub_device = sub_device
self.start_device_consumer()
# self.start_device_consumer(self.device) # for simulation
self.new_scan_id.connect(self.create_new_stream_consumer)
self.update_signal.connect(self.plot_new)
def init_ui(self):
# Create layout and add widgets
self.layout = QVBoxLayout()
self.setLayout(self.layout)
# Create plot
self.glw = pg.GraphicsLayoutWidget()
self.layout.addWidget(self.glw)
# Create Plot and add ImageItem
self.plot_item = pg.PlotItem()
self.plot_item.setAspectLocked(False)
self.imageItem = pg.ImageItem()
# self.plot_item1D = pg.PlotItem()
# self.plot_item.addItem(self.imageItem)
# self.plot_item.addItem(self.plot_item1D)
# Setting up histogram
# self.hist = pg.HistogramLUTItem()
# self.hist.setImageItem(self.imageItem)
# self.hist.gradient.loadPreset("magma")
# self.update_hist()
# Adding Items to Graphical Layout
self.glw.addItem(self.plot_item)
# self.glw.addItem(self.hist)
@pyqtSlot(str)
def create_new_stream_consumer(self, scan_id: str):
print(f"Creating new stream consumer for scan_id: {scan_id}")
self.connect_stream_consumer(scan_id, self.device)
def connect_stream_consumer(self, scan_id, device):
if self.stream_consumer is not None:
self.stream_consumer.shutdown()
self.stream_consumer = connector.stream_consumer(
topics=MessageEndpoints.device_async_readback(scan_id=scan_id, device=device),
cb=self._streamer_cb,
parent=self,
)
self.stream_consumer.start()
def start_device_consumer(self):
self.device_consumer = connector.consumer(
topics=MessageEndpoints.scan_status(), cb=self._device_cv, parent=self
)
self.device_consumer.start()
# def start_device_consumer(self, device): #for simulation
# self.device_consumer = connector.consumer(
# topics=MessageEndpoints.device_status(device), cb=self._device_cv, parent=self
# )
#
# self.device_consumer.start()
def plot_new(self):
print(f"Printing data from plot update: {self.data}")
self.plot_item.plot(self.data[0])
# self.imageItem.setImage(self.data, autoLevels=False)
@staticmethod
def _streamer_cb(msg, *, parent, **_kwargs) -> None:
msgMCS = msg.value
print(msgMCS)
row = msgMCS.content["signals"][parent.sub_device]
metadata = msgMCS.metadata
# Check if the current number of rows is odd
# if parent.data is not None and parent.data.shape[0] % 2 == 1:
# row = np.flip(row) # Flip the row
print(f"Printing data from callback update: {row}")
parent.data = np.array([row])
# if parent.data is None:
# parent.data = np.array([row])
# else:
# parent.data = np.vstack((parent.data, row))
parent.update_signal.emit()
@staticmethod
def _device_cv(msg, *, parent, **_kwargs) -> None:
print("Getting ScanID")
msgDEV = msg.value
current_scan_id = msgDEV.content["scan_id"]
if parent.scan_id is None:
parent.scan_id = current_scan_id
parent.new_scan_id.emit(current_scan_id)
print(f"New scan_id: {current_scan_id}")
if current_scan_id != parent.scan_id:
parent.scan_id = current_scan_id
# parent.data = None
# parent.imageItem.clear()
parent.new_scan_id.emit(current_scan_id)
print(f"New scan_id: {current_scan_id}")
if __name__ == "__main__":
import argparse
from bec_lib import RedisConnector
parser = argparse.ArgumentParser(description="Stream App.")
parser.add_argument("--port", type=str, default="pc15543:6379", help="Port for RedisConnector")
parser.add_argument("--device", type=str, default="mcs", help="Device name")
parser.add_argument("--sub_device", type=str, default="mca4", help="Sub-device name")
args = parser.parse_args()
connector = RedisConnector(args.port)
app = QApplication([])
streamApp = StreamApp(device=args.device, sub_device=args.sub_device)
streamApp.show()
app.exec()

View File

@@ -1,26 +0,0 @@
import time
from bec_lib import MessageEndpoints, RedisConnector, messages
connector = RedisConnector("localhost:6379")
metadata = {}
scan_id = "ScanID1"
metadata.update(
{"scan_id": scan_id, "async_update": "append"} # this will be different for each scan
)
for ii in range(20):
data = {"mca1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "mca2": [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]}
msg = messages.DeviceMessage(signals=data, metadata=metadata).dumps()
connector.xadd(
topic=MessageEndpoints.device_async_readback(
scan_id=scan_id, device="mca"
), # scan_id will be different for each scan
msg={"data": msg}, # TODO should be msg_dict
expire=1800,
)
print(f"Sent {ii}")
time.sleep(0.5)

View File

@@ -1,92 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1433</width>
<height>689</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="2">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Plot Config 2</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="BECMonitor" name="plot_1"/>
</item>
<item row="1" column="3">
<widget class="QPushButton" name="pushButton_setting_2">
<property name="text">
<string>Setting Plot 2</string>
</property>
</widget>
</item>
<item row="3" column="2" colspan="2">
<widget class="BECMonitor" name="plot_2"/>
</item>
<item row="1" column="4">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Plot Scan Types = True</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_setting_1">
<property name="text">
<string>Setting Plot 1</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Plot Config 1</string>
</property>
</widget>
</item>
<item row="1" column="5">
<widget class="QPushButton" name="pushButton_setting_3">
<property name="text">
<string>Setting Plot 3</string>
</property>
</widget>
</item>
<item row="3" column="4" colspan="2">
<widget class="BECMonitor" name="plot_3"/>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1433</width>
<height>37</height>
</rect>
</property>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<customwidgets>
<customwidget>
<class>BECMonitor</class>
<extends>QGraphicsView</extends>
<header location="global">bec_widgets.widgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,200 +0,0 @@
import os
from qtpy import uic
from qtpy.QtWidgets import QApplication, QMainWindow
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import BECMonitor
# some default configs for demonstration purposes
CONFIG_SIMPLE = {
"plot_settings": {
"background_color": "black",
"num_columns": 2,
"colormap": "plasma",
"scan_types": False,
},
"plot_data": [
{
"plot_name": "BPM4i plots vs samx",
"x_label": "Motor X",
"y_label": "bpm4i",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx"}],
"y": [{"name": "bpm4i", "entry": "bpm4i"}],
},
},
# {
# "type": "history",
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
# {
# "type": "dap",
# 'worker':'some_worker',
# "signals": {
# "x": [{"name": "samx"}],
# "y": [{"name": "bpm4i", "entry": "bpm4i"}],
# },
# },
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
}
CONFIG_SCAN_MODE = {
"plot_settings": {
"background_color": "white",
"num_columns": 3,
"colormap": "plasma",
"scan_types": True,
},
"plot_data": {
"grid_scan": [
{
"plot_name": "Grid plot 1",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}],
},
}
],
},
{
"plot_name": "Grid plot 2",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_adc1"}],
},
}
],
},
{
"plot_name": "Grid plot 3",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy"}],
"y": [{"name": "gauss_adc2"}],
},
}
],
},
{
"plot_name": "Grid plot 4",
"x_label": "Motor X",
"y_label": "BPM",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samy", "entry": "samy"}],
"y": [{"name": "gauss_adc3"}],
},
}
],
},
],
"line_scan": [
{
"plot_name": "BPM plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "bpm4i"}],
},
}
],
},
{
"plot_name": "Gauss plots vs samx",
"x_label": "Motor X",
"y_label": "Gauss",
"sources": [
{
"type": "scan_segment",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "gauss_bpm"}, {"name": "gauss_adc1"}],
},
}
],
},
],
},
}
class ModularApp(QMainWindow):
def __init__(self, client=None, parent=None):
super(ModularApp, self).__init__(parent)
# Client and device manager from BEC
self.client = BECDispatcher().client if client is None else client
# Loading UI
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "modular.ui"), self)
self._init_plots()
def _init_plots(self):
"""Initialize plots and connect the buttons to the config dialogs"""
plots = [self.plot_1, self.plot_2, self.plot_3]
configs = [CONFIG_SIMPLE, CONFIG_SCAN_MODE, CONFIG_SCAN_MODE]
buttons = [self.pushButton_setting_1, self.pushButton_setting_2, self.pushButton_setting_3]
# hook plots, configs and buttons together
for plot, config, button in zip(plots, configs, buttons):
plot.on_config_update(config)
button.clicked.connect(plot.show_config_dialog)
if __name__ == "__main__":
# BECclient global variables
client = BECDispatcher().client
client.start()
app = QApplication([])
modularApp = ModularApp(client=client)
window = modularApp
window.show()
app.exec()

View File

@@ -1,9 +0,0 @@
from .motor_control_compilations import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -1,17 +0,0 @@
selected_motors:
motor_x: "samx"
motor_y: "samy"
plot_motors:
max_points: 1000
num_dim_points: 100
scatter_size: 5
precision: 3
mode_lock: False # "Individual" or "Start/Stop". False to unlock
extra_columns:
- sample name: "sample 1"
- step_x [mu]: 25
- step_y [mu]: 25
- exp_time [s]: 1
- start: 1
- tilt [deg]: 0

View File

@@ -1,10 +0,0 @@
redis:
host: pc15543
port: 6379
mongodb:
host: localhost
port: 27017
scibec:
host: http://localhost
port: 3030
beamline: MyBeamline

View File

@@ -1,17 +0,0 @@
selected_motors:
motor_x: "samx"
motor_y: "samy"
plot_motors:
max_points: 1000
num_dim_points: 100
scatter_size: 5
precision: 3
mode_lock: Start/Stop # "Individual" or "Start/Stop"
extra_columns:
- sample name: "sample 1"
- step_x [mu]: 25
- step_y [mu]: 25
- exp_time [s]: 1
- start: 1
- tilt [deg]: 0

View File

@@ -1,255 +0,0 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import qdarktheme
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorMap,
MotorThread,
)
CONFIG_DEFAULT = {
"motor_control": {
"motor_x": "samx",
"motor_y": "samy",
"step_size_x": 3,
"step_size_y": 3,
"precision": 4,
"step_x_y_same": False,
"move_with_arrows": False,
},
"plot_settings": {
"colormap": "Greys",
"scatter_size": 5,
"max_points": 1000,
"num_dim_points": 100,
"precision": 2,
"num_columns": 1,
"background_value": 25,
},
"motors": [
{
"plot_name": "Motor Map",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
},
],
}
class MotorControlApp(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
self.motion_map = MotorMap(client=self.client, config=self.config)
# Create MotorCoordinateTable
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
splitter.addWidget(self.motor_table)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
lambda x, y: self.motion_map.change_motors(x, y, 0)
)
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
self.motor_table.add_coordinate
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_table.set_precision
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_control_panel.absolute_widget.set_precision
)
self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
class MotorControlMap(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
self.motion_map = MotorMap(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
self.motor_control_panel.selection_widget.selected_motors_signal.connect(
lambda x, y: self.motion_map.change_motors(x, y, 0)
)
class MotorControlPanel(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelAbsolute(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelRelative(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
# Set the window to a fixed size based on its contents
self.layout().setSizeConstraint(layout.SetFixedSize)
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
parser.add_argument(
"-v",
"--variant",
type=str,
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
help="Select the variant of the motor control to run. "
"'app' for the full application, "
"'map' for MotorMap, "
"'panel' for the MotorControlPanel, "
"'panel_abs' for MotorControlPanel with absolute control, "
"'panel_rel' for MotorControlPanel with relative control.",
)
args = parser.parse_args()
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication([])
qdarktheme.setup_theme("auto")
if args.variant == "app":
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "map":
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel":
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_abs":
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_rel":
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
else:
print("Please specify a valid variant to run. Use -h for help.")
print("Running the full application by default.")
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
window.show()
sys.exit(app.exec())

View File

@@ -1,926 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1561</width>
<height>748</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1409</width>
<height>748</height>
</size>
</property>
<property name="windowTitle">
<string>Motor Controller</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="8,5,8">
<item>
<widget class="GraphicsLayoutWidget" name="glw">
<property name="enabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="Controls">
<property name="minimumSize">
<size>
<width>221</width>
<height>471</height>
</size>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
<property name="spacing">
<number>1</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<widget class="QGroupBox" name="motorSelection">
<property name="minimumSize">
<size>
<width>261</width>
<height>145</height>
</size>
</property>
<property name="title">
<string>Motor Selection</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Motor Y</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="comboBox_motor_x"/>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="comboBox_motor_y"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Motor X</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QPushButton" name="pushButton_connecMotors">
<property name="text">
<string>Connect Motors</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl">
<property name="minimumSize">
<size>
<width>261</width>
<height>339</height>
</size>
</property>
<property name="title">
<string>Motor Relative</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QCheckBox" name="checkBox_enableArrows">
<property name="text">
<string>Move with arrow keys</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_same_xy">
<property name="text">
<string>Step [X] = Step [Y]</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="step_grid">
<item row="2" column="0">
<widget class="QLabel" name="label_step_y">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [Y]</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Decimal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_x">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_step_x">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [X]</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_y">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_precision">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>8</number>
</property>
<property name="value">
<number>2</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="direction_grid">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_up">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::UpArrow</enum>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_down">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::DownArrow</enum>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QToolButton" name="toolButton_left">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::LeftArrow</enum>
</property>
</widget>
</item>
<item row="0" column="2">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="3">
<widget class="QToolButton" name="toolButton_right">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::RightArrow</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="2">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl_absolute">
<property name="minimumSize">
<size>
<width>261</width>
<height>195</height>
</size>
</property>
<property name="title">
<string>Move Absolute</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_save_with_go">
<property name="text">
<string>Save position with Go</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_save">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_set">
<property name="text">
<string>Set</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_go_absolute">
<property name="text">
<string>Go</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="pushButton_stop">
<property name="text">
<string>Stop Movement</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget_tables">
<property name="enabled">
<bool>true</bool>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_coordinates">
<attribute name="title">
<string>Coordinates</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Entries Mode:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_mode">
<item>
<property name="text">
<string>Individual</string>
</property>
</item>
<item>
<property name="text">
<string>Start/Stop</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_coordinates">
<property name="selectionMode">
<enum>QAbstractItemView::MultiSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<column>
<property name="text">
<string>Show</string>
</property>
</column>
<column>
<property name="text">
<string>Move</string>
</property>
</column>
<column>
<property name="text">
<string>Tag</string>
</property>
</column>
<column>
<property name="text">
<string>X</string>
</property>
</column>
<column>
<property name="text">
<string>Y</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_resize_table">
<property name="text">
<string>Resize Table</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="checkBox_resize_auto">
<property name="text">
<string>Resize Auto</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="pushButton_importCSV">
<property name="text">
<string>Import CSV</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pushButton_exportCSV">
<property name="text">
<string>Export CSV</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="pushButton_help">
<property name="text">
<string>Help</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_duplicate">
<property name="text">
<string>Duplicate Last Entry</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_settings">
<attribute name="title">
<string>Settings</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QGroupBox" name="motorLimits">
<property name="enabled">
<bool>false</bool>
</property>
<property name="title">
<string>Motor Limits</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_updateLimits">
<property name="text">
<string>Update</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_Y_max">
<property name="text">
<string>+ Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_Y_min">
<property name="text">
<string>- Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_X_min">
<property name="text">
<string>- X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_X_max">
<property name="text">
<string>+ X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QDoubleSpinBox" name="spinBox_x_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="spinBox_x_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Plotting Options</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="0" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_max_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>100</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="singleStep">
<number>100</number>
</property>
<property name="value">
<number>5000</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="2" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_scatter_size">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>15</number>
</property>
<property name="value">
<number>5</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_update_config">
<property name="text">
<string>Update Settings</string>
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_num_dim_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="singleStep">
<number>10</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_16">
<property name="text">
<string>N dim</string>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_enableGUI">
<property name="text">
<string>Enable Control GUI</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_queue">
<attribute name="title">
<string>Queue</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Work in progress</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_5">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Reset Queue</string>
</property>
</widget>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_2">
<property name="enabled">
<bool>false</bool>
</property>
<column>
<property name="text">
<string>queueID</string>
</property>
</column>
<column>
<property name="text">
<string>scan_id</string>
</property>
</column>
<column>
<property name="text">
<string>is_scan</string>
</property>
</column>
<column>
<property name="text">
<string>type</string>
</property>
</column>
<column>
<property name="text">
<string>scan_number</string>
</property>
</column>
<column>
<property name="text">
<string>IQ status</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
import sys
from bec_ipython_client.main import BECIPythonClient
from qtpy.QtWidgets import QApplication
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = TicTacToe()
window.state = "-X-XO----"
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,13 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.examples.plugin_example_pyside.tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
if __name__ == "__main__": # pragma: no cover
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())

View File

@@ -0,0 +1,4 @@
{
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
"tictactoetaskmenu.py"]
}

View File

@@ -0,0 +1,136 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
from qtpy.QtGui import QPainter, QPen
from qtpy.QtWidgets import QWidget
EMPTY = "-"
CROSS = "X"
NOUGHT = "O"
DEFAULT_STATE = "---------"
class TicTacToe(QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
print("TicTac HERE !!!!!!")
def minimumSizeHint(self):
return QSize(200, 200)
def sizeHint(self):
return QSize(200, 200)
def setState(self, new_state):
self._turn_number = 0
self._state = DEFAULT_STATE
for position in range(min(9, len(new_state))):
mark = new_state[position]
if mark == CROSS or mark == NOUGHT:
self._turn_number += 1
self._change_state_at(position, mark)
position += 1
self.update()
def state(self):
return self._state
@Slot()
def clear_board(self):
self._state = DEFAULT_STATE
self._turn_number = 0
self.update()
def _change_state_at(self, pos, new_state):
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
def mousePressEvent(self, event):
if self._turn_number == 9:
self.clear_board()
return
for position in range(9):
cell = self._cell_rect(position)
if cell.contains(event.position().toPoint()):
if self._state[position] == EMPTY:
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
self._change_state_at(position, new_state)
self._turn_number += 1
self.update()
def paintEvent(self, event):
with QPainter(self) as painter:
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(QPen(Qt.darkGreen, 1))
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
painter.setPen(QPen(Qt.darkBlue, 2))
for position in range(9):
cell = self._cell_rect(position)
if self._state[position] == CROSS:
painter.drawLine(cell.topLeft(), cell.bottomRight())
painter.drawLine(cell.topRight(), cell.bottomLeft())
elif self._state[position] == NOUGHT:
painter.drawEllipse(cell)
painter.setPen(QPen(Qt.yellow, 3))
for position in range(0, 8, 3):
if (
self._state[position] != EMPTY
and self._state[position + 1] == self._state[position]
and self._state[position + 2] == self._state[position]
):
y = self._cell_rect(position).center().y()
painter.drawLine(0, y, self.width(), y)
self._turn_number = 9
for position in range(3):
if (
self._state[position] != EMPTY
and self._state[position + 3] == self._state[position]
and self._state[position + 6] == self._state[position]
):
x = self._cell_rect(position).center().x()
painter.drawLine(x, 0, x, self.height())
self._turn_number = 9
if (
self._state[0] != EMPTY
and self._state[4] == self._state[0]
and self._state[8] == self._state[0]
):
painter.drawLine(0, 0, self.width(), self.height())
self._turn_number = 9
if (
self._state[2] != EMPTY
and self._state[4] == self._state[2]
and self._state[6] == self._state[2]
):
painter.drawLine(0, self.height(), self.width(), 0)
self._turn_number = 9
def _cell_rect(self, position):
h_margin = self.width() / 30
v_margin = self.height() / 30
row = int(position / 3)
column = position - 3 * row
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
return QRect(pos, size)
def _cell_width(self):
return self.width() / 3
def _cell_height(self):
return self.height() / 3
state = Property(str, state, setState)

View File

@@ -0,0 +1,73 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu import TicTacToeTaskMenuFactory
from bec_widgets.utils.bec_designer import designer_material_icon
DOM_XML = """
<ui language='c++'>
<widget class='TicTacToe' name='ticTacToe'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>200</width>
<height>200</height>
</rect>
</property>
<property name='state'>
<string>-X-XO----</string>
</property>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = TicTacToe(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Games"
def icon(self):
return designer_material_icon("sports_esports")
def includeFile(self):
return "tictactoe"
def initialize(self, form_editor):
self._form_editor = form_editor
manager = form_editor.extensionManager()
iid = TicTacToeTaskMenuFactory.task_menu_iid()
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "TicTacToe"
def toolTip(self):
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,68 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
class TicTacToeDialog(QDialog): # pragma: no cover
def __init__(self, parent):
super().__init__(parent)
layout = QVBoxLayout(self)
self._ticTacToe = TicTacToe(self)
layout.addWidget(self._ticTacToe)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
reset_button = button_box.button(QDialogButtonBox.Reset)
reset_button.clicked.connect(self._ticTacToe.clear_board)
layout.addWidget(button_box)
def set_state(self, new_state):
self._ticTacToe.setState(new_state)
def state(self):
return self._ticTacToe.state
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
def __init__(self, ticTacToe, parent):
super().__init__(parent)
self._ticTacToe = ticTacToe
self._edit_state_action = QAction("Edit State...", None)
self._edit_state_action.triggered.connect(self._edit_state)
def taskActions(self):
return [self._edit_state_action]
def preferredEditAction(self):
return self._edit_state_action
@Slot()
def _edit_state(self):
dialog = TicTacToeDialog(self._ticTacToe)
dialog.set_state(self._ticTacToe.state)
if dialog.exec() == QDialog.Accepted:
self._ticTacToe.state = dialog.state()
class TicTacToeTaskMenuFactory(QExtensionFactory):
def __init__(self, extension_manager):
super().__init__(extension_manager)
@staticmethod
def task_menu_iid():
return "org.qt-project.Qt.Designer.TaskMenu"
def createExtension(self, object, iid, parent):
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
return None
if object.__class__.__name__ != "TicTacToe":
return None
return TicTacToeTaskMenu(object, parent)

View File

@@ -1,155 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>845</width>
<height>635</height>
</rect>
</property>
<property name="windowTitle">
<string>Line Plot</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QSplitter" name="splitter_plot">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>1</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="GraphicsLayoutWidget" name="glw_plot"/>
<widget class="GraphicsLayoutWidget" name="glw_image"/>
</widget>
<widget class="QWidget" name="">
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,1,1,15">
<item>
<widget class="QPushButton" name="pushButton_generate">
<property name="text">
<string>Generate 1D and 2D data without stream</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>1st angle of azimutal segment (deg)</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QDoubleSpinBox" name="doubleSpinBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximum">
<double>360.000000000000000</double>
</property>
<property name="singleStep">
<double>0.250000000000000</double>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<item>
<property name="text">
<string>f1amp</string>
</property>
</item>
<item>
<property name="text">
<string>f2amp</string>
</property>
</item>
<item>
<property name="text">
<string>f2 phase</string>
</property>
</item>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="spinBox_precision">
<property name="value">
<number>4</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="cursor_table">
<property name="textElideMode">
<enum>Qt::ElideMiddle</enum>
</property>
<column>
<property name="text">
<string>Display</string>
</property>
</column>
<column>
<property name="text">
<string>X</string>
</property>
</column>
<column>
<property name="text">
<string>Y</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,336 +0,0 @@
import os
import threading
import time
import numpy as np
import pyqtgraph
import pyqtgraph as pg
from bec_lib import MessageEndpoints, messages
from bec_lib.redis_connector import RedisConnector
from pyqtgraph import mkBrush, mkPen
from pyqtgraph.Qt import QtCore, QtWidgets, uic
from pyqtgraph.Qt.QtCore import pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtWidgets import QTableWidgetItem
from bec_widgets.utils import Colors, Crosshair
from bec_widgets.utils.bec_dispatcher import BECDispatcher
class StreamPlot(QtWidgets.QWidget):
update_signal = pyqtSignal()
roi_signal = pyqtSignal(tuple)
def __init__(self, name="", y_value_list=["gauss_bpm"], client=None, parent=None) -> None:
"""
Basic plot widget for displaying scan data.
Args:
name (str, optional): Name of the plot. Defaults to "".
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
"""
# Client and device manager from BEC
self.client = BECDispatcher().client if client is None else client
super(StreamPlot, self).__init__()
# Set style for pyqtgraph plots
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
current_path = os.path.dirname(__file__)
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
self._idle_time = 100
self.connector = RedisConnector(["localhost:6379"])
self.y_value_list = y_value_list
self.previous_y_value_list = None
self.plotter_data_x = []
self.plotter_data_y = []
self.plotter_scan_id = None
self._current_proj = None
self._current_metadata_ep = "px_stream/projection_{}/metadata"
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
self._data_retriever_thread_exit_event = threading.Event()
self.data_retriever = threading.Thread(
target=self.on_projection, args=(self._data_retriever_thread_exit_event,), daemon=True
)
self.data_retriever.start()
##########################
# UI
##########################
self.init_ui()
self.init_curves()
self.hook_crosshair()
def close(self):
super().close()
self._data_retriever_thread_exit_event.set()
self.data_retriever.join()
def init_ui(self):
"""Setup all ui elements"""
##########################
# 1D Plot
##########################
# LabelItem for ROI
self.label_plot = pg.LabelItem(justify="center")
self.glw_plot.addItem(self.label_plot)
self.label_plot.setText("ROI region")
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
self.roi_selector = pg.LinearRegionItem([-1, 1])
self.glw_plot.nextRow() # TODO update of cursor
self.label_plot_moved = pg.LabelItem(justify="center")
self.glw_plot.addItem(self.label_plot_moved)
self.label_plot_moved.setText("Actual coordinates (X, Y)")
# Label for coordinates clicked
self.glw_plot.nextRow()
self.label_plot_clicked = pg.LabelItem(justify="center")
self.glw_plot.addItem(self.label_plot_clicked)
self.label_plot_clicked.setText("Clicked coordinates (X, Y)")
# 1D PlotItem
self.glw_plot.nextRow()
self.plot = pg.PlotItem()
self.plot.setLogMode(True, True)
self.glw_plot.addItem(self.plot)
self.plot.addLegend()
##########################
# 2D Plot
##########################
# Label for coordinates moved
self.label_image_moved = pg.LabelItem(justify="center")
self.glw_image.addItem(self.label_image_moved)
self.label_image_moved.setText("Actual coordinates (X, Y)")
# Label for coordinates clicked
self.glw_image.nextRow()
self.label_image_clicked = pg.LabelItem(justify="center")
self.glw_image.addItem(self.label_image_clicked)
self.label_image_clicked.setText("Clicked coordinates (X, Y)")
# TODO try to lock aspect ratio with view
# # Create a window
# win = pg.GraphicsLayoutWidget()
# win.show()
#
# # Create a ViewBox
# view = win.addViewBox()
#
# # Lock the aspect ratio
# view.setAspectLocked(True)
# # Create an ImageItem
# image_item = pg.ImageItem(np.random.random((100, 100)))
#
# # Add the ImageItem to the ViewBox
# view.addItem(image_item)
# 2D ImageItem
self.glw_image.nextRow()
self.plot_image = pg.PlotItem()
self.glw_image.addItem(self.plot_image)
def init_curves(self):
# init of 1D plot
self.plot.clear()
self.curves = []
self.pens = []
self.brushs = []
self.color_list = Colors.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
for ii, y_value in enumerate(self.y_value_list):
pen = mkPen(color=self.color_list[ii], width=2, style=QtCore.Qt.DashLine)
brush = mkBrush(color=self.color_list[ii])
curve = pg.PlotDataItem(symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value)
self.plot.addItem(curve)
self.curves.append(curve)
self.pens.append(pen)
self.brushs.append(brush)
# check if roi selector is in the plot
if self.roi_selector not in self.plot.items:
self.plot.addItem(self.roi_selector)
# init of 2D plot
self.plot_image.clear()
self.img = pg.ImageItem()
self.plot_image.addItem(self.img)
# hooking signals
self.hook_crosshair()
self.init_table()
def splitter_sizes(self): ...
def hook_crosshair(self):
self.crosshair_1d = Crosshair(self.plot, precision=4)
self.crosshair_1d.coordinatesChanged1D.connect(
lambda x, y: self.label_plot_moved.setText(f"Moved : ({x}, {y})")
)
self.crosshair_1d.coordinatesClicked1D.connect(
lambda x, y: self.label_plot_clicked.setText(f"Moved : ({x}, {y})")
)
self.crosshair_1d.coordinatesChanged1D.connect(
lambda x, y: self.update_table(table_widget=self.cursor_table, x=x, y_values=y)
)
self.crosshair_2D = Crosshair(self.plot_image)
self.crosshair_2D.coordinatesChanged2D.connect(
lambda x, y: self.label_image_moved.setText(f"Moved : ({x}, {y})")
)
self.crosshair_2D.coordinatesClicked2D.connect(
lambda x, y: self.label_image_clicked.setText(f"Moved : ({x}, {y})")
)
# ROI
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
def get_roi_region(self):
"""For testing purpose now, get roi region and print it to self.label as tuple"""
region = self.roi_selector.getRegion()
self.label_plot.setText(f"x = {(10 ** region[0]):.4f}, y ={(10 ** region[1]):.4f}")
return_dict = {
"horiz_roi": [
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
]
}
msg = messages.DeviceMessage(signals=return_dict).dumps()
self.connector.set_and_publish("px_stream/gui_event", msg=msg)
self.roi_signal.emit(region)
def init_table(self):
# Init number of rows in table according to n of devices
self.cursor_table.setRowCount(len(self.y_value_list))
# self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) #TODO can be dynamic
self.cursor_table.setVerticalHeaderLabels(self.y_value_list)
self.cursor_table.resizeColumnsToContents()
def update_table(self, table_widget, x, y_values):
for i, y in enumerate(y_values):
table_widget.setItem(i, 1, QTableWidgetItem(str(x)))
table_widget.setItem(i, 2, QTableWidgetItem(str(y)))
table_widget.resizeColumnsToContents()
def update(self):
"""Update the plot with the new data."""
# check if QTable was initialised and if list of devices was changed
# if self.y_value_list != self.previous_y_value_list:
# self.setup_cursor_table()
# self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
@staticmethod
def flip_even_rows(arr):
arr_copy = np.copy(arr) # Create a writable copy
arr_copy[1::2, :] = arr_copy[1::2, ::-1]
return arr_copy
@staticmethod
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
"""Removes a curve from the given plot by the specified name.
Args:
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
name (str): The name of the curve to remove.
"""
# if checkbox.isChecked():
for item in plot.items:
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
plot.removeItem(item)
return
# else:
# return
def on_projection(self, exit_event):
while not exit_event.is_set():
if self._current_proj is None:
time.sleep(0.1)
continue
endpoint = f"px_stream/projection_{self._current_proj}/data"
msgs = self.client.connector.lrange(topic=endpoint, start=-1, end=-1)
data = msgs
if not data:
continue
with np.errstate(divide="ignore", invalid="ignore"):
self.plotter_data_y = [
np.sum(
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
/ np.sum(self._current_norm, axis=0),
axis=0,
).squeeze()
]
self.update_signal.emit()
@pyqtSlot(dict, dict)
def on_dap_update(self, data: dict, metadata: dict):
flipped_data = self.flip_even_rows(data["data"]["z"])
self.img.setImage(flipped_data)
@pyqtSlot(dict, dict)
def new_proj(self, content: dict, _metadata: dict):
proj_nr = content["signals"]["proj_nr"]
endpoint = f"px_stream/projection_{proj_nr}/metadata"
msg_raw = self.client.connector.get(topic=endpoint)
msg = messages.DeviceMessage.loads(msg_raw)
self._current_q = msg.content["signals"]["q"]
self._current_norm = msg.content["signals"]["norm_sum"]
self._current_metadata = msg.content["signals"]["metadata"]
self.plotter_data_x = [self._current_q]
self._current_proj = proj_nr
if __name__ == "__main__":
import argparse
# from bec_widgets import ctrl_c # TODO uncomment when ctrl_c is ready to be compatible with qtpy
parser = argparse.ArgumentParser()
parser.add_argument(
"--signals", help="specify recorded signals", nargs="+", default=["gauss_bpm"]
)
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
value = parser.parse_args()
print(f"Plotting signals for: {', '.join(value.signals)}")
# Client from dispatcher
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
app = QtWidgets.QApplication([])
# ctrl_c.setup(app) # TODO uncomment when ctrl_c is ready to be compatible with qtpy
plot = StreamPlot(y_value_list=value.signals, client=client)
bec_dispatcher.connect_slot(plot.new_proj, "px_stream/proj_nr")
bec_dispatcher.connect_slot(
plot.on_dap_update, MessageEndpoints.processed_data("px_dap_worker")
)
plot.show()
# client.callbacks.register("scan_segment", plot, sync=False)
app.exec()

View File

@@ -0,0 +1,214 @@
import functools
import sys
import traceback
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
to the passed function, to display errors instead of potentially raising an exception
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
otherwise error display is left to the original exception hook
"""
popup_error = bool(slot_kwargs.pop("popup_error", False))
def error_managed(method):
@Slot(*slot_args, **slot_kwargs)
@functools.wraps(method)
def wrapper(*args, **kwargs):
try:
return method(*args, **kwargs)
except Exception:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
return wrapper
return error_managed
class WarningPopupUtility(QObject):
"""
Utility class to show warning popups in the application.
"""
@SafeSlot(str, str, str, QWidget)
def show_warning_message(self, title, message, detailed_text, widget):
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle(title)
msg.setText(message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.exec_()
def show_warning(self, title: str, message: str, detailed_text: str, widget: QWidget = None):
"""
Show a warning message with the given title, message, and detailed text.
Args:
title (str): The title of the warning message.
message (str): The main text of the warning message.
detailed_text (str): The detailed text to show when the user expands the message.
widget (QWidget): The parent widget for the message box.
"""
self.show_warning_message(title, message, detailed_text, widget)
_popup_utility_instance = None
class _ErrorPopupUtility(QObject):
"""
Utility class to manage error popups in the application to show error messages to the users.
This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
"""
error_occurred = Signal(str, str, QWidget)
def __init__(self, parent=None):
super().__init__(parent=parent)
self.error_occurred.connect(self.show_error_message)
self.enable_error_popup = False
self._initialized = True
sys.excepthook = self.custom_exception_hook
@SafeSlot(str, str, QWidget)
def show_error_message(self, title, message, widget):
detailed_text = self.format_traceback(message)
error_message = self.parse_error_message(detailed_text)
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle(title)
msg.setText(error_message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
msg.setMinimumWidth(600)
msg.setMinimumHeight(400)
msg.exec_()
def format_traceback(self, traceback_message: str) -> str:
"""
Format the traceback message to be displayed in the error popup by adding indentation to each line.
Args:
traceback_message(str): The traceback message to be formatted.
Returns:
str: The formatted traceback message.
"""
formatted_lines = []
lines = traceback_message.split("\n")
for line in lines:
formatted_lines.append(" " + line) # Add indentation to each line
return "\n".join(formatted_lines)
def parse_error_message(self, traceback_message):
lines = traceback_message.split("\n")
error_message = "Error occurred. See details."
capture = False
captured_message = []
for line in lines:
if "raise" in line:
capture = True
continue
if capture:
if line.strip() and not line.startswith(" File "):
captured_message.append(line.strip())
else:
break
if captured_message:
error_message = " ".join(captured_message)
return error_message
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
if popup_error or self.enable_error_popup:
error_message = traceback.format_exception(exctype, value, tb)
self.error_occurred.emit(
"Method error" if popup_error else "Application Error",
"".join(error_message),
self.parent(),
)
else:
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
def enable_global_error_popups(self, state: bool):
"""
Enable or disable global error popups for all applications.
Args:
state(bool): True to enable error popups, False to disable error popups.
"""
self.enable_error_popup = bool(state)
def ErrorPopupUtility():
global _popup_utility_instance
if not _popup_utility_instance:
_popup_utility_instance = _ErrorPopupUtility()
return _popup_utility_instance
class ExampleWidget(QWidget): # pragma: no cover
"""
Example widget to demonstrate error handling with the ErrorPopupUtility.
Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.init_ui()
self.warning_utility = WarningPopupUtility(self)
def init_ui(self):
self.layout = QVBoxLayout(self)
# Button to trigger method with error handling
self.error_button = QPushButton("Trigger Handled Error", self)
self.error_button.clicked.connect(self.method_with_error_handling)
self.layout.addWidget(self.error_button)
# Button to trigger method without error handling
self.normal_button = QPushButton("Trigger Normal Error", self)
self.normal_button.clicked.connect(self.method_without_error_handling)
self.layout.addWidget(self.normal_button)
# Button to trigger warning popup
self.warning_button = QPushButton("Trigger Warning", self)
self.warning_button.clicked.connect(self.trigger_warning)
self.layout.addWidget(self.warning_button)
@SafeSlot(popup_error=True)
def method_with_error_handling(self):
"""This method raises an error and the exception is handled by the decorator."""
raise ValueError("This is a handled error.")
@SafeSlot()
def method_without_error_handling(self):
"""This method raises an error and the exception is not handled here."""
raise ValueError("This is an unhandled error.")
@SafeSlot()
def trigger_warning(self):
"""Trigger a warning using the WarningPopupUtility."""
self.warning_utility.show_warning(
title="Warning",
message="This is a warning message.",
detailed_text="This is the detailed text of the warning message.",
widget=self,
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
widget = ExampleWidget()
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,47 @@
from bec_lib.serialization import MsgpackSerialization
from bec_lib.utils import lazy_import_from
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})

View File

@@ -0,0 +1,119 @@
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
class SettingWidget(QWidget):
"""
Abstract class for a settings widget to enforce the implementation of the accept_changes and display_current_settings.
Can be used for toolbar actions to display the settings of a widget.
Args:
target_widget (QWidget): The widget that the settings will be taken from and applied to.
"""
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.target_widget = None
def set_target_widget(self, target_widget: QWidget):
self.target_widget = target_widget
@Slot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
pass
@Slot(dict)
def display_current_settings(self, config_dict: dict):
"""
Displays the current settings of the target widget in the settings widget.
Args:
config_dict(dict): The current settings of the target widget.
"""
pass
class SettingsDialog(QDialog):
"""
Dialog to display and edit the settings of a widget with accept and cancel buttons.
Args:
parent (QWidget): The parent widget of the dialog.
target_widget (QWidget): The widget that the settings will be taken from and applied to.
settings_widget (SettingWidget): The widget that will display the settings.
"""
def __init__(
self,
parent=None,
settings_widget: SettingWidget = None,
window_title: str = "Settings",
config: dict = None,
*args,
**kwargs,
):
super().__init__(parent, *args, **kwargs)
self.setModal(False)
self.setWindowTitle(window_title)
self.widget = settings_widget
self.widget.set_target_widget(parent)
if config is None:
config = parent.get_config()
self.widget.display_current_settings(config)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.apply_button = QPushButton("Apply")
button_layout = QHBoxLayout()
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Cancel))
button_layout.addWidget(self.apply_button)
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Ok))
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self.apply_button.clicked.connect(self.apply_changes)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
self.layout.addWidget(self.widget)
self.layout.addLayout(button_layout)
ok_button = self.button_box.button(QDialogButtonBox.Ok)
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
@Slot()
def accept(self):
"""
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
super().accept()
@Slot()
def apply_changes(self):
"""
Apply the changes made in the settings widget without closing the dialog.
"""
self.widget.accept_changes()
def cleanup(self):
"""
Cleanup the dialog.
"""
self.button_box.close()
self.button_box.deleteLater()
def closeEvent(self, event):
self.cleanup()
super().closeEvent(event)

View File

@@ -0,0 +1,219 @@
# pylint: disable=no-name-in-module
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QIcon
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMenu, QToolBar, QToolButton, QWidget
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
self.icon_path = (
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
)
self.tooltip = tooltip
self.checkable = checkable
self.action = None
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action or widget to.
target (QWidget): The target widget for the action.
"""
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
toolbar.addSeparator()
class IconAction(ToolBarAction):
"""
Action with an icon for the toolbar.
Args:
icon_path (str): The path to the icon file.
tooltip (str): The tooltip for the action.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
super().__init__(icon_path, tooltip, checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = QIcon()
icon.addFile(self.icon_path, size=QSize(20, 20))
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
class MaterialIconAction:
"""
Action with a Material icon for the toolbar.
Args:
icon_path (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
checkable: bool = False,
filled: bool = False,
):
self.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
self.icon_name, size=(20, 20), convert_to_pixmap=False, filled=self.filled
)
return icon
class DeviceSelectionAction(ToolBarAction):
"""
Action for selecting a device in a combobox.
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
super().__init__()
self.label = label
self.device_combobox = device_combobox
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
layout = QHBoxLayout(widget)
label = QLabel(f"{self.label}")
layout.addWidget(label)
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
Args:
label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
super().__init__(icon_path, label)
self.actions = actions
self.widgets = defaultdict(dict)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
if hasattr(action, "icon_path"):
icon = QIcon()
icon.addFile(action.icon_path, size=QSize(20, 20))
sub_action.setIcon(icon)
elif hasattr(action, "get_icon"):
sub_action.setIcon(action.get_icon())
sub_action.setCheckable(action.checkable)
menu.addAction(sub_action)
self.widgets[action_id] = sub_action
button.setMenu(menu)
toolbar.addWidget(button)
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
"""
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
super().__init__(parent)
self.widgets = defaultdict(dict)
self.set_background_color()
if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget)
def populate_toolbar(self, actions: dict, target_widget):
"""Populates the toolbar with a set of actions.
Args:
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
def set_background_color(self):
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)

View File

@@ -1,3 +1,5 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable
@@ -5,5 +7,7 @@ from .colors import Colors
from .container_utils import WidgetContainerUtils
from .crosshair import Crosshair
from .entry_validator import EntryValidator
from .layout_manager import GridLayoutManager
from .rpc_decorator import register_rpc_methods, rpc_public
from .ui_loader import UILoader
from .validator_delegate import DoubleValidationDelegate

View File

@@ -1,13 +1,23 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import os
import time
from typing import Optional, Type
import uuid
from typing import Optional
import yaml
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
class ConnectionConfig(BaseModel):
@@ -17,8 +27,10 @@ class ConnectionConfig(BaseModel):
gui_id: Optional[str] = Field(
default=None, validate_default=True, description="The GUI ID of the widget."
)
model_config: dict = {"validate_assignment": True}
@field_validator("gui_id")
@classmethod
def generate_gui_id(cls, v, values):
"""Generate a GUI ID if none is provided."""
if v is None:
@@ -28,16 +40,55 @@ class ConnectionConfig(BaseModel):
return v
class BECConnector:
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
class WorkerSignals(QObject):
progress = Signal(dict)
completed = Signal()
USER_ACCESS = ["config_dict"]
class Worker(QRunnable):
"""
Worker class to run a function in a separate thread.
"""
def __init__(self, func, *args, **kwargs):
super().__init__()
self.signals = WorkerSignals()
self.func = func
self.args = args
self.kwargs = kwargs
def run(self):
"""
Run the specified function in the thread.
"""
self.func(*self.args, **self.kwargs)
self.signals.completed.emit()
class BECConnector:
"""Connection mixin class to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
EXIT_HANDLERS = {}
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@pyqtSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
print("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
print("Shutting down BEC Client", repr(client))
client.shutdown()
BECConnector.EXIT_HANDLERS[self.client] = terminate
QApplication.instance().aboutToQuit.connect(terminate)
if config:
self.config = config
self.config.widget_class = self.__class__.__name__
@@ -54,28 +105,146 @@ class BECConnector:
else:
self.gui_id = self.config.gui_id
# register widget to rpc register
# be careful: when registering, and the object is not a BECWidget,
# cleanup has to called manually since there is no 'closeEvent'
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
# Error popups
self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance()
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
Use this method if you want to wait for a task to complete without blocking the
main thread.
Args:
fn: Function to run in a separate thread.
*args: Arguments for the function.
on_complete: Slot to run when the task is complete.
**kwargs: Keyword arguments for the function.
Returns:
worker: The worker object that will run the task.
Examples:
>>> def my_function(a, b):
>>> print(a + b)
>>> self.submit_task(my_function, 1, 2)
>>> def my_function(a, b):
>>> print(a + b)
>>> def on_complete():
>>> print("Task complete")
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
"""
worker = Worker(fn, *args, **kwargs)
if on_complete:
worker.signals.completed.connect(on_complete)
self._thread_pool.start(worker)
return worker
def _get_all_rpc(self) -> dict:
"""Get all registered RPC objects."""
all_connections = self.rpc_register.list_all_connections()
return dict(all_connections)
@property
def config_dict(self) -> dict:
def _rpc_id(self) -> str:
"""Get the RPC ID of the widget."""
return self.gui_id
@_rpc_id.setter
def _rpc_id(self, rpc_id: str) -> None:
"""Set the RPC ID of the widget."""
self.gui_id = rpc_id
@property
def _config_dict(self) -> dict:
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
return self.config.model_dump()
@config_dict.setter
def config_dict(self, config: BaseModel) -> None:
@_config_dict.setter
def _config_dict(self, config: BaseModel) -> None:
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
self.config = config
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
Args:
config(dict): Configuration settings.
generate_new_id(bool): If True, generate a new GUI ID for the widget.
"""
self.config = ConnectionConfig(**config)
if generate_new_id is True:
gui_id = str(uuid.uuid4())
self.rpc_register.remove_rpc(self)
self.set_gui_id(gui_id)
self.rpc_register.add_rpc(self)
else:
self.gui_id = self.config.gui_id
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
Args:
path(str): Path to the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to load the configuration file.
"""
if gui is True:
config = load_yaml_gui(self)
else:
config = load_yaml(path)
if config is not None:
if config.get("widget_class") != self.__class__.__name__:
raise ValueError(
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
)
self.apply_config(config)
def save_config(self, path: str | None = None, gui: bool = False):
"""
Save the configuration of the widget to YAML.
Args:
path(str): Path to save the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to save the configuration file.
"""
if gui is True:
save_yaml_gui(self, self._config_dict)
else:
if path is None:
path = os.getcwd()
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
@pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
Args:
gui_id(str): GUI ID
"""
@@ -96,6 +265,7 @@ class BECConnector:
def update_client(self, client) -> None:
"""Update the client and device manager from BEC and create object for BEC shortcuts.
Args:
client: BEC client
"""
@@ -106,6 +276,7 @@ class BECConnector:
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
Args:
config(ConnectionConfig): Configuration settings.
"""
@@ -118,8 +289,10 @@ class BECConnector:
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""
Get the configuration of the widget.
Args:
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
Returns:
dict: The configuration of the plot widget.
"""
@@ -127,7 +300,3 @@ class BECConnector:
return self.config.model_dump()
else:
return self.config
def closeEvent(self, event):
self.client.shutdown()
super().closeEvent(event)

View File

@@ -0,0 +1,153 @@
import importlib.metadata
import json
import os
import site
import sys
import sysconfig
from pathlib import Path
from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
ui_tool_binary,
)
import bec_widgets
def designer_material_icon(icon_name: str) -> QIcon:
"""
Create a QIcon for the BECDesigner with the given material icon name.
Args:
icon_name (str): The name of the material icon.
Returns:
QIcon: The QIcon for the material icon.
"""
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
def list_editable_packages() -> set[str]:
"""
List all editable packages in the environment.
Returns:
set: A set of paths to editable packages.
"""
editable_packages = set()
# Get site-packages directories
site_packages = site.getsitepackages()
if hasattr(site, "getusersitepackages"):
site_packages.append(site.getusersitepackages())
for dist in importlib.metadata.distributions():
location = dist.locate_file("").resolve()
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
if is_editable:
editable_packages.add(str(location))
for packages in site_packages:
# all dist-info directories in site-packages that contain a direct_url.json file
dist_info_dirs = Path(packages).rglob("*.dist-info")
for dist_info_dir in dist_info_dirs:
direct_url = dist_info_dir / "direct_url.json"
if not direct_url.exists():
continue
# load the json file and get the path to the package
with open(direct_url, "r", encoding="utf-8") as f:
data = json.load(f)
path = data.get("url", "")
if path.startswith("file://"):
path = path[7:]
editable_packages.add(path)
return editable_packages
def patch_designer(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
init_virtual_env()
major_version = sys.version_info[0]
minor_version = sys.version_info[1]
os.environ["PY_MAJOR_VERSION"] = str(major_version)
os.environ["PY_MINOR_VERSION"] = str(minor_version)
if sys.platform == "win32":
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
else:
if sys.platform == "linux":
suffix = f"{sys.abiflags}.so"
env_var = "LD_PRELOAD"
elif sys.platform == "darwin":
suffix = ".dylib"
env_var = "DYLD_INSERT_LIBRARIES"
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{suffix}"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ[env_var] = lib_path
if is_pyenv_python() or is_virtual_env():
# append all editable packages to the PYTHONPATH
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def find_plugin_paths(base_path: Path):
"""
Recursively find all directories containing a .pyproject file.
"""
plugin_paths = []
for path in base_path.rglob("*.pyproject"):
plugin_paths.append(str(path.parent))
return plugin_paths
def set_plugin_environment_variable(plugin_paths):
"""
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
"""
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
if current_paths:
current_paths = current_paths.split(os.pathsep)
else:
current_paths = []
current_paths.extend(plugin_paths)
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
# Patch the designer function
def main(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
set_plugin_environment_variable(plugin_paths)
patch_designer()
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,13 +1,13 @@
from __future__ import annotations
import argparse
import collections
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
import redis
from bec_lib import BECClient
from bec_lib.client import BECClient
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
@@ -65,19 +65,24 @@ 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"""
_instance = None
_initialized = False
def __new__(cls, client=None, *args, **kwargs):
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None):
def __init__(self, client=None, config: str | ServiceConfig = None):
if self._initialized:
return
@@ -85,7 +90,17 @@ class BECDispatcher:
self.client = client
if self.client is None:
self.client = BECClient(connector_cls=QtRedisConnector, forced=True)
if config is not None:
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)
else:
if self.client.started:
# have to reinitialize client to use proper connector
@@ -108,8 +123,9 @@ class BECDispatcher:
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
**kwargs,
) -> None:
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
@@ -117,18 +133,39 @@ class BECDispatcher:
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
"""
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot)
self.client.connector.register(topics, cb=slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
self.client.connector.unregister(topics, cb=slot)
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics(Union[str, list]): The topic(s) to disconnect from
"""
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
for connected_slot in self._slots:
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].difference_update(set(topics_str))
if not self._slots[slot]:
del self._slots[slot]
self._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
Disconnect all slots from a topic.
Args:
topics(Union[str, list]): The topic(s) to disconnect from
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
@@ -138,4 +175,11 @@ class BECDispatcher:
del self._slots[slot]
def disconnect_all(self, *args, **kwargs):
"""
Disconnect all slots from all topics.
Args:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
self.disconnect_topics(self.client.connector._topics_cb)

View File

@@ -8,6 +8,7 @@ class BECTable(QTableWidget):
def keyPressEvent(self, event) -> None:
"""
Delete selected rows with backspace or delete key
Args:
event: keyPressEvent
"""

View File

@@ -0,0 +1,32 @@
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
class BECWidget(BECConnector):
"""Mixin class for all BEC widgets, to handle cleanup"""
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client, config, gui_id)
# Set the theme to auto if it is not set yet
app = QApplication.instance()
if not hasattr(app, "theme"):
set_theme("dark")
def cleanup(self):
"""Cleanup the widget."""
def closeEvent(self, event):
self.rpc_register.remove_rpc(self)
try:
self.cleanup()
finally:
super().closeEvent(event)

View File

@@ -1,11 +1,68 @@
import itertools
import re
from typing import Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
def get_theme_palette():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme["theme"]
return bec_qthemes.load_palette(theme)
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme)
pg.setConfigOption("background", "w" if app.theme["theme"] == "light" else "k")
apply_theme(theme)
# pylint: disable=protected-access
if theme != "auto":
return
def callback():
app.theme["theme"] = listener._theme.lower()
apply_theme(listener._theme.lower())
listener = OSThemeSwitchListener(callback)
app.installEventFilter(listener)
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
app = QApplication.instance()
# go through all pyqtgraph widgets and set background
children = itertools.chain.from_iterable(
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
)
for pg_widget in children:
pg_widget.setBackground("k" if theme == "dark" else "w")
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
class Colors:
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
@@ -52,8 +109,19 @@ class Colors:
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
for ii in color_selection[:num]:
color = cmap_colors[int(ii)]
ii = 0
while len(colors) < num:
color_index = int(color_selection[ii])
color = cmap_colors[color_index]
app = QApplication.instance()
if hasattr(app, "theme") and app.theme["theme"] == "light":
background = 255
else:
background = 0
if np.abs(np.mean(color[:3] * 255) - background) < 50:
ii += 1
continue
if format.upper() == "HEX":
colors.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
@@ -62,4 +130,251 @@ class Colors:
colors.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
ii += 1
return colors
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
"""
Convert HEX color to RGBA.
Args:
hex_color(str): HEX color string.
alpha(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
tuple: RGBA color tuple (r, g, b, a).
"""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 6:
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
elif len(hex_color) == 8:
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
return (r, g, b, a)
else:
raise ValueError("HEX color must be 6 or 8 characters long.")
return (r, g, b, alpha)
@staticmethod
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
"""
Convert RGBA color to HEX.
Args:
r(int): Red value (0-255).
g(int): Green value (0-255).
b(int): Blue value (0-255).
a(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
hec_color(str): HEX color string.
"""
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
@staticmethod
def validate_color(color: tuple | str) -> tuple | str:
"""
Validate the color input if it is HEX or RGBA compatible. Can be used in any pydantic model as a field validator.
Args:
color(tuple|str): The color to be validated. Can be a tuple of RGBA values or a HEX string.
Returns:
tuple|str: The validated color.
"""
CSS_COLOR_NAMES = {
"aliceblue",
"antiquewhite",
"aqua",
"aquamarine",
"azure",
"beige",
"bisque",
"black",
"blanchedalmond",
"blue",
"blueviolet",
"brown",
"burlywood",
"cadetblue",
"chartreuse",
"chocolate",
"coral",
"cornflowerblue",
"cornsilk",
"crimson",
"cyan",
"darkblue",
"darkcyan",
"darkgoldenrod",
"darkgray",
"darkgreen",
"darkgrey",
"darkkhaki",
"darkmagenta",
"darkolivegreen",
"darkorange",
"darkorchid",
"darkred",
"darksalmon",
"darkseagreen",
"darkslateblue",
"darkslategray",
"darkslategrey",
"darkturquoise",
"darkviolet",
"deeppink",
"deepskyblue",
"dimgray",
"dimgrey",
"dodgerblue",
"firebrick",
"floralwhite",
"forestgreen",
"fuchsia",
"gainsboro",
"ghostwhite",
"gold",
"goldenrod",
"gray",
"green",
"greenyellow",
"grey",
"honeydew",
"hotpink",
"indianred",
"indigo",
"ivory",
"khaki",
"lavender",
"lavenderblush",
"lawngreen",
"lemonchiffon",
"lightblue",
"lightcoral",
"lightcyan",
"lightgoldenrodyellow",
"lightgray",
"lightgreen",
"lightgrey",
"lightpink",
"lightsalmon",
"lightseagreen",
"lightskyblue",
"lightslategray",
"lightslategrey",
"lightsteelblue",
"lightyellow",
"lime",
"limegreen",
"linen",
"magenta",
"maroon",
"mediumaquamarine",
"mediumblue",
"mediumorchid",
"mediumpurple",
"mediumseagreen",
"mediumslateblue",
"mediumspringgreen",
"mediumturquoise",
"mediumvioletred",
"midnightblue",
"mintcream",
"mistyrose",
"moccasin",
"navajowhite",
"navy",
"oldlace",
"olive",
"olivedrab",
"orange",
"orangered",
"orchid",
"palegoldenrod",
"palegreen",
"paleturquoise",
"palevioletred",
"papayawhip",
"peachpuff",
"peru",
"pink",
"plum",
"powderblue",
"purple",
"red",
"rosybrown",
"royalblue",
"saddlebrown",
"salmon",
"sandybrown",
"seagreen",
"seashell",
"sienna",
"silver",
"skyblue",
"slateblue",
"slategray",
"slategrey",
"snow",
"springgreen",
"steelblue",
"tan",
"teal",
"thistle",
"tomato",
"turquoise",
"violet",
"wheat",
"white",
"whitesmoke",
"yellow",
"yellowgreen",
}
if isinstance(color, str):
hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
if hex_pattern.match(color):
return color
elif color.lower() in CSS_COLOR_NAMES:
return color
else:
raise PydanticCustomError(
"unsupported color",
"The color must be a valid HEX string or CSS Color.",
{"wrong_value": color},
)
elif isinstance(color, tuple):
if len(color) != 4:
raise PydanticCustomError(
"unsupported color",
"The color must be a tuple of 4 elements (R, G, B, A).",
{"wrong_value": color},
)
for value in color:
if not 0 <= value <= 255:
raise PydanticCustomError(
"unsupported color",
f"The color values must be between 0 and 255 in RGBA format (R,G,B,A)",
{"wrong_value": color},
)
return color
@staticmethod
def validate_color_map(color_map: str) -> str:
"""
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
Args:
color_map(str): The colormap to be validated.
Returns:
str: The validated colormap.
"""
available_colormaps = pg.colormap.listMaps()
if color_map not in available_colormaps:
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
{"wrong_value": color_map},
)
return color_map

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import itertools
from typing import Type
@@ -10,6 +12,7 @@ class WidgetContainerUtils:
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
Args:
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
@@ -29,10 +32,12 @@ class WidgetContainerUtils:
) -> QWidget | None:
"""
Find the first widget of a given class in the figure.
Args:
container(dict): The container of widgets.
widget_class(Type): The class of the widget to find.
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
Returns:
widget: The widget of the given class.
"""

View File

@@ -1,22 +1,27 @@
from collections import defaultdict
import numpy as np
import pyqtgraph as pg
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject
from qtpy.QtCore import QObject, Qt
from qtpy.QtCore import Signal as pyqtSignal
class Crosshair(QObject):
positionChanged = pyqtSignal(tuple)
positionClicked = pyqtSignal(tuple)
# Signal for 1D plot
coordinatesChanged1D = pyqtSignal(float, list)
coordinatesClicked1D = pyqtSignal(float, list)
coordinatesChanged1D = pyqtSignal(tuple)
coordinatesClicked1D = pyqtSignal(tuple)
# Signal for 2D plot
coordinatesChanged2D = pyqtSignal(float, float)
coordinatesClicked2D = pyqtSignal(float, float)
coordinatesChanged2D = pyqtSignal(tuple)
coordinatesClicked2D = pyqtSignal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
"""
Crosshair for 1D and 2D plots.
Args:
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
@@ -25,10 +30,13 @@ class Crosshair(QObject):
super().__init__(parent)
self.is_log_y = None
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self.precision = precision
self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
self.h_line.skip_auto_range = True
self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True)
self.proxy = pg.SignalProxy(
@@ -36,74 +44,75 @@ class Crosshair(QObject):
)
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
# Initialize markers
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_2d = None
self.update_markers()
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
# Clear existing markers
for marker in self.marker_moved_1d + self.marker_clicked_1d:
self.plot_item.removeItem(marker)
if self.marker_2d:
self.plot_item.removeItem(self.marker_2d)
# Create new markers
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
if item.name() in self.marker_moved_1d:
continue
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
marker_moved = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_clicked = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
)
self.marker_moved_1d.append(marker_moved)
marker_moved.skip_auto_range = True
self.marker_moved_1d[item.name()] = marker_moved
self.plot_item.addItem(marker_moved)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = pg.ScatterPlotItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_clicked_list.append(marker_clicked)
marker_clicked.skip_auto_range = True
self.marker_clicked_1d[item.name()] = marker_clicked
self.plot_item.addItem(marker_clicked)
self.marker_clicked_1d.append(marker_clicked_list)
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
continue
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
)
self.plot_item.addItem(self.marker_2d)
def snap_to_data(self, x, y) -> tuple:
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x: The x-coordinate
y: The y-coordinate
x: The x-coordinate of the mouse cursor
y: The y-coordinate of the mouse cursor
Returns:
tuple: The nearest x and y values
tuple: x and y values snapped to the nearest data
"""
y_values_1d = []
x_values_1d = []
y_values = defaultdict(list)
x_values = defaultdict(list)
image_2d = None
# Iterate through items in the plot
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
x_data, y_data = item.xData, item.yData
name = item.name()
plot_data = item._getDisplayDataset()
if plot_data is None:
continue
x_data, y_data = plot_data.x, plot_data.y
if x_data is not None and y_data is not None:
if self.is_log_x:
min_x_data = np.min(x_data[x_data > 0])
@@ -111,25 +120,25 @@ class Crosshair(QObject):
min_x_data = np.min(x_data)
max_x_data = np.max(x_data)
if x < min_x_data or x > max_x_data:
return None, None
y_values[name] = None
x_values[name] = None
continue
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
y_values_1d.append(closest_y)
x_values_1d.append(closest_x)
y_values[name] = closest_y
x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor
image_2d = item.image
# clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
# Handle 1D plot
if y_values_1d:
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
v is None for v in y_values.values()
):
return None, None
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
return closest_x, y_values_1d
# Handle 2D plot
if image_2d is not None:
x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1))
y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1))
return x_idx, y_idx
return x_values, y_values
return None, None
@@ -155,8 +164,9 @@ class Crosshair(QObject):
Args:
event: The mouse moved event
"""
self.check_log()
pos = event[0]
self.update_markers()
self.positionChanged.emit((pos.x(), pos.y()))
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
self.v_line.setPos(mouse_point.x())
@@ -167,25 +177,34 @@ class Crosshair(QObject):
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
if x is None or all(v is None for v in y_values):
return
self.coordinatesChanged1D.emit(
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
for i, y_val in enumerate(y_values):
self.marker_moved_1d[i].setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
name = item.name()
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
if x is None or y_values is None:
return
self.coordinatesChanged2D.emit(x, y_values)
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -193,38 +212,69 @@ class Crosshair(QObject):
Args:
event: The mouse clicked event
"""
self.check_log()
# we only accept left mouse clicks
if event.button() != Qt.MouseButton.LeftButton:
return
self.update_markers()
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
x, y = mouse_point.x(), mouse_point.y()
self.positionClicked.emit((x, y))
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
if x is None or all(v is None for v in y_values):
return
self.coordinatesClicked1D.emit(
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
for i, y_val in enumerate(y_values):
for marker in self.marker_clicked_1d[i]:
marker.setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
name = item.name()
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_clicked_1d[name].setData([x], [y])
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
if x is None or y_values is None:
return
self.coordinatesClicked2D.emit(x, y_values)
self.marker_2d.setPos([x, y_values])
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
marker.clear()
for marker in self.marker_clicked_1d.values():
marker.clear()
def check_log(self):
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
self.clear_markers()
def check_derivatives(self):
"""Checks if the derivatives are enabled and updates the internal state accordingly."""
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
def cleanup(self):
self.v_line.deleteLater()
self.h_line.deleteLater()
self.clear_markers()

View File

@@ -1,39 +0,0 @@
# TODO haven't found yet how to deal with QAbstractSocket in qtpy
# import signal
# import socket
# from PyQt5.QtNetwork import QAbstractSocket
#
#
# def setup(app):
# app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
# signal.signal(signal.SIGINT, make_quit_handler(app))
#
#
# def make_quit_handler(app):
# def handler(*args):
# print() # make ^C appear on its own line
# app.quit()
#
# return handler
#
#
# class SignalWatchdog(QAbstractSocket):
# def __init__(self):
# """
# Propagates system signals from Python to QEventLoop
# adapted from https://stackoverflow.com/a/65802260/655404
# """
# super().__init__(QAbstractSocket.SctpSocket, None)
#
# self.writer, self.reader = writer, reader = socket.socketpair()
# writer.setblocking(False)
#
# fd_writer = writer.fileno()
# fd_reader = reader.fileno()
#
# signal.set_wakeup_fd(fd_writer) # Python hook
# self.setSocketDescriptor(fd_reader) # Qt hook
#
# self.readyRead.connect(
# lambda: None
# ) # dummy function call that lets the Python interpreter run

View File

@@ -5,6 +5,7 @@ class EntryValidator:
def validate_signal(self, name: str, entry: str = None) -> str:
"""
Validate a signal entry for a given device. If the entry is not provided, the first signal entry will be used from the device hints.
Args:
name(str): Device name
entry(str): Signal entry
@@ -18,7 +19,7 @@ class EntryValidator:
device = self.devices[name]
description = device.describe()
if entry is None:
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
@@ -28,6 +29,7 @@ class EntryValidator:
def validate_monitor(self, monitor: str) -> str:
"""
Validate a monitor entry for a given device.
Args:
monitor(str): Monitor entry

View File

@@ -0,0 +1,149 @@
import inspect
import os
import re
from qtpy.QtCore import QObject
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
class DesignerPluginInfo:
def __init__(self, plugin_class):
self.plugin_class = plugin_class
self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
)
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
# first sentence / line of the docstring is used as tooltip
self.plugin_tooltip = (
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
if plugin_class.__doc__
else self.plugin_name_pascal
)
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
@staticmethod
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()
class DesignerPluginGenerator:
def __init__(self, widget: type):
self._excluded = False
self.widget = widget
self.info = DesignerPluginInfo(widget)
if widget.__name__ in EXCLUDED_PLUGINS:
self._excluded = True
return
self.templates = {}
self.template_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
def run(self, validate=True):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
return
if validate:
self._check_class_validity()
self._load_templates()
self._write_templates()
def _check_class_validity(self):
# Check if the widget is a QWidget subclass
if not issubclass(self.widget, QObject):
return
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
signature = list(inspect.signature(self.widget.__init__).parameters.values())
if len(signature) == 1 or signature[1].name != "parent":
raise ValueError(
f"Widget class {self.widget.__name__} must have parent as the first argument."
)
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
if not base_cls:
raise ValueError(
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
)
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
)
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
if not cls_init_found and not super_init_found:
raise ValueError(
f"Widget class {self.widget.__name__} must call the super constructor with parent."
)
def _write_templates(self):
self._write_register()
self._write_plugin()
self._write_pyproject()
def _write_register(self):
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.templates["register"].format(**self.info.__dict__))
def _write_plugin(self):
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.templates["plugin"].format(**self.info.__dict__))
def _write_pyproject(self):
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
with open(file_path, "w", encoding="utf-8") as f:
f.write(str(out))
def _load_templates(self):
for file in os.listdir(self.template_path):
if not file.endswith(".template"):
continue
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
self.templates[file.split(".")[0]] = f.read()
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False)

View File

@@ -0,0 +1,121 @@
from collections import OrderedDict
from typing import Literal
from qtpy.QtWidgets import QGridLayout, QWidget
class GridLayoutManager:
"""
GridLayoutManager class is used to manage widgets in a QGridLayout and extend its functionality.
The GridLayoutManager class provides methods to add, move, and check the position of widgets in a QGridLayout.
It also provides a method to get the positions of all widgets in the layout.
Args:
layout(QGridLayout): The layout to manage.
"""
def __init__(self, layout: QGridLayout):
self.layout = layout
def is_position_occupied(self, row: int, col: int) -> bool:
"""
Check if the position in the layout is occupied by a widget.
Args:
row(int): The row to check.
col(int): The column to check.
Returns:
bool: True if the position is occupied, False otherwise.
"""
for i in range(self.layout.count()):
widget_row, widget_col, _, _ = self.layout.getItemPosition(i)
if widget_row == row and widget_col == col:
return True
return False
def shift_widgets(
self,
direction: Literal["down", "up", "left", "right"] = "down",
start_row: int = 0,
start_col: int = 0,
):
"""
Shift widgets in the layout in the specified direction starting from the specified position.
Args:
direction(str): The direction to shift the widgets. Can be "down", "up", "left", or "right".
start_row(int): The row to start shifting from. Default is 0.
start_col(int): The column to start shifting from. Default is 0.
"""
for i in reversed(range(self.layout.count())):
widget_item = self.layout.itemAt(i)
widget = widget_item.widget()
row, col, rowspan, colspan = self.layout.getItemPosition(i)
if direction == "down" and row >= start_row:
self.layout.addWidget(widget, row + 1, col, rowspan, colspan)
elif direction == "up" and row > start_row:
self.layout.addWidget(widget, row - 1, col, rowspan, colspan)
elif direction == "right" and col >= start_col:
self.layout.addWidget(widget, row, col + 1, rowspan, colspan)
elif direction == "left" and col > start_col:
self.layout.addWidget(widget, row, col - 1, rowspan, colspan)
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
"""
Move a widget to a new position in the layout.
Args:
widget(QWidget): The widget to move.
new_row(int): The new row to move the widget to.
new_col(int): The new column to move the widget to.
"""
self.layout.removeWidget(widget)
self.layout.addWidget(widget, new_row, new_col)
def add_widget(
self,
widget: QWidget,
row=None,
col=0,
rowspan=1,
colspan=1,
shift: Literal["down", "up", "left", "right"] = "down",
):
"""
Add a widget to the layout at the specified position.
Args:
widget(QWidget): The widget to add.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to. Default is 0.
rowspan(int): The number of rows the widget will span. Default is 1.
colspan(int): The number of columns the widget will span. Default is 1.
shift(str): The direction to shift the widgets if the position is occupied. Can be "down", "up", "left", or "right".
"""
if row is None:
row = self.layout.rowCount()
if self.is_position_occupied(row, col):
self.shift_widgets(shift, start_row=row)
self.layout.addWidget(widget, row, col, rowspan, colspan)
def get_widgets_positions(self) -> dict:
"""
Get the positions of all widgets in the layout.
Returns:
dict: A dictionary with the positions of the widgets in the layout.
"""
positions = []
for i in range(self.layout.count()):
widget_item = self.layout.itemAt(i)
widget = widget_item.widget()
if widget:
position = self.layout.getItemPosition(i)
positions.append((position, widget))
positions.sort(key=lambda x: (x[0][0], x[0][1], x[0][2], x[0][3]))
ordered_positions = OrderedDict()
for pos, widget in positions:
ordered_positions[pos] = widget
return ordered_positions

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
{widget_import}
DOM_XML = """
<ui language='c++'>
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
</widget>
</ui>
"""
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = {plugin_name_pascal}(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon({plugin_name_pascal}.ICON_NAME)
def includeFile(self):
return "{plugin_name_snake}"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "{plugin_name_pascal}"
def toolTip(self):
return "{plugin_tooltip}"
def whatsThis(self):
return self.toolTip()

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
{plugin_import}
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,158 @@
import importlib
import inspect
import os
from dataclasses import dataclass
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
the following key:
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "path.to.plugin.module"
e.g.
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
Returns:
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.user_widgets")
loaded_plugins = {}
print(modules)
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_plugins)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated widgets plugin {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
@dataclass
class BECClassInfo:
name: str
module: str
file: str
obj: type
is_connector: bool = False
is_widget: bool = False
is_top_level: bool = False
class BECClassContainer:
def __init__(self):
self._collection = []
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
Args:
class_info(BECClassInfo): The class information
"""
self.collection.append(class_info)
@property
def collection(self):
"""
Get the collection of classes.
"""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_top_level]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
def get_rpc_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
collection = BECClassContainer()
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
collection.add_class(class_info)
return collection

View File

@@ -0,0 +1,92 @@
import os
import sys
from PIL import Image, ImageChops
from qtpy.QtGui import QPixmap
import bec_widgets
REFERENCE_DIR = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/references"
)
REFERENCE_DIR_FAILURES = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/reference_failures"
)
def compare_images(image1_path: str, reference_image_path: str):
"""
Load two images and compare them pixel by pixel
Args:
image1_path(str): The path to the first image
reference_image_path(str): The path to the reference image
Raises:
ValueError: If the images are different
"""
image1 = Image.open(image1_path)
image2 = Image.open(reference_image_path)
if image1.size != image2.size:
raise ValueError("Image size has changed")
diff = ImageChops.difference(image1, image2)
if diff.getbbox():
# copy image1 to the reference directory to upload as artifact
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, os.path.basename(image1_path))
image1.save(image_name)
print(f"Image saved to {image_name}")
raise ValueError("Images are different")
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
"""
Save a rendering of a widget and compare it to a reference image
Args:
widget(any): The widget to render
output_directory(str): The directory to save the image to
suffix(str): A suffix to append to the image name
Raises:
ValueError: If the images are different
Examples:
snap_and_compare(widget, tmpdir, suffix="started")
"""
if not isinstance(output_directory, str):
output_directory = str(output_directory)
os_suffix = sys.platform
name = (
f"{widget.__class__.__name__}_{suffix}_{os_suffix}.png"
if suffix
else f"{widget.__class__.__name__}_{os_suffix}.png"
)
# Save the widget to a pixmap
test_image_path = os.path.join(output_directory, name)
pixmap = QPixmap(widget.size())
widget.render(pixmap)
pixmap.save(test_image_path)
try:
reference_path = os.path.join(REFERENCE_DIR, f"{widget.__class__.__name__}")
reference_image_path = os.path.join(reference_path, name)
if not os.path.exists(reference_image_path):
raise ValueError(f"Reference image not found: {reference_image_path}")
compare_images(test_image_path, reference_image_path)
except ValueError:
image = Image.open(test_image_path)
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, name)
image.save(image_name)
print(f"Image saved to {image_name}")
raise

View File

@@ -0,0 +1,140 @@
import os
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_rpc_classes
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
self.baseinstance = baseinstance
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
widget = self.custom_widgets[class_name](parent)
widget.setObjectName(name)
return widget
return super().createWidget(class_name, parent, name)
class UILoader:
"""Universal UI loader for PyQt6 and PySide6."""
def __init__(self, parent=None):
self.parent = parent
widgets = get_rpc_classes("bec_widgets").top_level_classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:
self.loader = self.load_ui_pyqt6
else:
raise ImportError("No compatible Qt bindings found.")
def load_ui_pyside6(self, ui_file, parent=None):
"""
Specific loader for PySide6 using QUiLoader.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
loader = CustomUiLoader(parent, self.custom_widgets)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")
widget = loader.load(file, parent)
file.close()
return widget
def load_ui_pyqt6(self, ui_file, parent=None):
"""
Specific loader for PyQt6 using loadUi.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
from PyQt6.uic.Loader.loader import DynamicUILoader
class CustomDynamicUILoader(DynamicUILoader):
def __init__(self, package, custom_widgets: dict = None):
super().__init__(package)
self.custom_widgets = custom_widgets or {}
def _handle_custom_widgets(self, el):
"""Handle the <customwidgets> element."""
def header2module(header):
"""header2module(header) -> string
Convert paths to C++ header files to according Python modules
>>> header2module("foo/bar/baz.h")
'foo.bar.baz'
"""
if header.endswith(".h"):
header = header[:-2]
mpath = []
for part in header.split("/"):
# Ignore any empty parts or those that refer to the current
# directory.
if part not in ("", "."):
if part == "..":
# We should allow this for Python3.
raise SyntaxError(
"custom widget header file name may not contain '..'."
)
mpath.append(part)
return ".".join(mpath)
for custom_widget in el:
classname = custom_widget.findtext("class")
header = custom_widget.findtext("header")
if header:
header = self._translate_bec_widgets_header(header)
self.factory.addCustomWidget(
classname,
custom_widget.findtext("extends") or "QWidget",
header2module(header),
)
def _translate_bec_widgets_header(self, header):
for name, value in self.custom_widgets.items():
if header == DesignerPluginInfo.pascal_to_snake(name):
return value.__module__
return header
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
def load_ui(self, ui_file, parent=None):
"""
Universal UI loader method.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
if parent is None:
parent = self.parent
return self.loader(ui_file, parent)

View File

@@ -44,8 +44,11 @@ class ComboBoxHandler(WidgetHandler):
def get_value(self, widget: QComboBox) -> int:
return widget.currentIndex()
def set_value(self, widget: QComboBox, value: int) -> None:
widget.setCurrentIndex(value)
def set_value(self, widget: QComboBox, value: int | str) -> None:
if isinstance(value, str):
value = widget.findText(value)
if isinstance(value, int):
widget.setCurrentIndex(value)
class TableWidgetHandler(WidgetHandler):
@@ -114,11 +117,12 @@ class WidgetIO:
def get_value(widget, ignore_errors=False):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._handlers.get(type(widget))
handler_class = WidgetIO._find_handler(widget)
if handler_class:
return handler_class().get_value(widget) # Instantiate the handler
if not ignore_errors:
@@ -129,17 +133,54 @@ class WidgetIO:
def set_value(widget, value, ignore_errors=False):
"""
Set a value on the widget instance.
Args:
widget: Widget instance.
value: Value to set.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._handlers.get(type(widget))
handler_class = WidgetIO._find_handler(widget)
if handler_class:
handler_class().set_value(widget, value) # Instantiate the handler
elif not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
@staticmethod
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
"""
Check if the new limits are within the current limits, if not adjust the limits.
Args:
number(float): The new value to check against the limits.
"""
min_value = spin_box.minimum()
max_value = spin_box.maximum()
# Calculate the new limits
new_limit = number + 5 * number
if number < min_value:
spin_box.setMinimum(new_limit)
elif number > max_value:
spin_box.setMaximum(new_limit)
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in WidgetIO._handlers:
return WidgetIO._handlers[base]
return None
################## for exporting and importing widget hierarchies ##################
@@ -155,6 +196,7 @@ class WidgetHierarchy:
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of
indent(int, optional): Level of indentation.
@@ -196,6 +238,7 @@ class WidgetHierarchy:
) -> dict:
"""
Export the widget hierarchy to a dictionary.
Args:
widget: Widget to print the hierarchy of.
config(dict,optional): Dictionary to export the hierarchy to.
@@ -245,6 +288,7 @@ class WidgetHierarchy:
def import_config_from_dict(widget, config: dict, set_values: bool = False) -> None:
"""
Import the widget hierarchy from a dictionary.
Args:
widget: Widget to import the hierarchy to.
config:

View File

@@ -6,9 +6,10 @@ import yaml
from qtpy.QtWidgets import QFileDialog
def load_yaml(instance) -> Union[dict, None]:
def load_yaml_gui(instance) -> Union[dict, None]:
"""
Load YAML file from disk.
Args:
instance: Instance of the calling widget.
@@ -19,12 +20,25 @@ def load_yaml(instance) -> Union[dict, None]:
file_path, _ = QFileDialog.getOpenFileName(
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
config = load_yaml(file_path)
return config
def load_yaml(file_path: str) -> Union[dict, None]:
"""
Load YAML file from disk.
Args:
file_path(str): Path to the YAML file.
Returns:
dict: Configuration data loaded from the YAML file.
"""
if not file_path:
return None
try:
with open(file_path, "r") as file:
config = yaml.safe_load(file)
config = yaml.load(file, Loader=yaml.FullLoader)
return config
except FileNotFoundError:
@@ -37,9 +51,10 @@ def load_yaml(instance) -> Union[dict, None]:
print(f"An error occurred while loading the settings from {file_path}: {e}")
def save_yaml(instance, config: dict) -> None:
def save_yaml_gui(instance, config: dict) -> None:
"""
Save YAML file to disk.
Args:
instance: Instance of the calling widget.
config: Configuration data to be saved.
@@ -49,6 +64,17 @@ def save_yaml(instance, config: dict) -> None:
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
)
save_yaml(file_path, config)
def save_yaml(file_path: str, config: dict) -> None:
"""
Save YAML file to disk.
Args:
file_path(str): Path to the YAML file.
config(dict): Configuration data to be saved.
"""
if not file_path:
return
try:

View File

@@ -1,2 +0,0 @@
# from .monitor_config import validate_monitor_config, ValidationError
from .monitor_config_validator import MonitorConfigValidator

View File

@@ -1,257 +0,0 @@
from typing import Literal, Optional, Union
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from pydantic_core import PydanticCustomError
class Signal(BaseModel):
"""
Represents a signal in a plot configuration.
Attributes:
name (str): The name of the signal.
entry (Optional[str]): The entry point of the signal, optional.
"""
name: str
entry: Optional[str] = Field(None, validate_default=True)
@model_validator(mode="before")
@classmethod
def validate_fields(cls, values):
"""Validate the fields of the model.
First validate the 'name' field, then validate the 'entry' field.
Args:
values (dict): The values to be validated."""
devices = MonitorConfigValidator.devices
# Validate 'name'
name = values.get("name")
# Check if device name provided
if name is None:
raise PydanticCustomError(
"no_device_name", "Device name must be provided", {"wrong_value": name}
)
# Check if device exists in BEC
if name not in devices:
raise PydanticCustomError(
"no_device_bec",
'Device "{wrong_value}" not found in current BEC session',
{"wrong_value": name},
)
device = devices[name] # get the device to check if it has signals
# Get device description
description = device.describe()
# Validate 'entry'
entry = values.get("entry")
# Set entry based on hints if not provided
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise PydanticCustomError(
"no_entry_for_device",
'Entry "{wrong_value}" not found in device "{device_name}" signals',
{"wrong_value": entry, "device_name": name},
)
values["entry"] = entry
return values
class AxisSignal(BaseModel):
"""
Configuration signal axis for a single plot.
Attributes:
x (list): Signal for the X axis.
y (list): Signals for the Y axis.
"""
x: list[Signal] = Field(default_factory=list)
y: list[Signal] = Field(default_factory=list)
@field_validator("x")
@classmethod
def validate_x_signals(cls, v):
"""Ensure that there is only one signal for x-axis."""
if len(v) != 1:
raise PydanticCustomError(
"x_axis_multiple_signals",
'There must be exactly one signal for x axis. Number of x signals: "{wrong_value}"',
{"wrong_value": v},
)
return v
class SourceHistoryValidator(BaseModel):
"""History source validator
Attributes:
type (str): type of source - history
scan_id (str): Scan ID for history source.
signals (list): Signal for the source.
"""
type: Literal["history"]
scan_id: str # TODO can be validated if it is a valid scan_id
signals: AxisSignal
class SourceSegmentValidator(BaseModel):
"""Scan Segment source validator
Attributes:
type (str): type of source - scan_segment
signals (AxisSignal): Signal for the source.
"""
type: Literal["scan_segment"]
signals: AxisSignal
class SourceRedisValidator(BaseModel):
"""Scan Segment source validator
Attributes:
type (str): type of source - scan_segment
endpoint (str): Endpoint reference in redis.
update (str): Update type.
"""
type: Literal["redis"]
endpoint: str
update: str
signals: dict
class Source(BaseModel): # TODO decide if it should stay for general Source validation
"""
General source validation, includes all Optional arguments of all other sources.
Attributes:
type (list): type of source (scan_segment, history)
scan_id (Optional[str]): Scan ID for history source.
signals (Optional[AxisSignal]): Signal for the source.
"""
type: Literal["scan_segment", "history", "redis"]
scan_id: Optional[str] = None
signals: Optional[dict] = None
class PlotConfig(BaseModel):
"""
Configuration for a single plot.
Attributes:
plot_name (Optional[str]): Name of the plot.
x_label (Optional[str]): The label for the x-axis.
y_label (Optional[str]): The label for the y-axis.
sources (list): A list of sources to be plotted on this axis.
"""
plot_name: Optional[str] = None
x_label: Optional[str] = None
y_label: Optional[str] = None
sources: list = Field(default_factory=list)
@field_validator("sources")
@classmethod
def validate_sources(cls, values):
"""Validate the sources of the plot configuration, based on the type of source."""
validated_sources = []
for source in values:
# Check if source type is supported
Source(**source)
source_type = source.get("type", None)
# Validate source based on type
if source_type == "scan_segment":
validated_sources.append(SourceSegmentValidator(**source))
elif source_type == "history":
validated_sources.append(SourceHistoryValidator(**source))
elif source_type == "redis":
validated_sources.append(SourceRedisValidator(**source))
return validated_sources
class PlotSettings(BaseModel):
"""
Global settings for plotting affecting mostly visuals.
Attributes:
background_color (str): Color of the plot background. Default is black.
axis_width (Optional[int]): Width of the plot axes. Default is 2.
axis_color (Optional[str]): Color of the plot axes. Default is None.
num_columns (int): Number of columns in the plot layout. Default is 1.
colormap (str): Colormap to be used. Default is magma.
scan_types (bool): Indicates if the configuration is for different scan types. Default is False.
"""
background_color: Literal["black", "white"] = "black"
axis_width: Optional[int] = 2
axis_color: Optional[str] = None
num_columns: Optional[int] = 1
colormap: Optional[str] = "magma"
scan_types: Optional[bool] = False
class DeviceMonitorConfig(BaseModel):
"""
Configuration model for the device monitor mode.
Attributes:
plot_settings (PlotSettings): Global settings for plotting.
plot_data (list[PlotConfig]): List of plot configurations.
"""
plot_settings: PlotSettings
plot_data: list[PlotConfig]
class ScanModeConfig(BaseModel):
"""
Configuration model for scan mode.
Attributes:
plot_settings (PlotSettings): Global settings for plotting.
plot_data (dict[str, list[PlotConfig]]): Dictionary of plot configurations,
keyed by scan type.
"""
plot_settings: PlotSettings
plot_data: dict[str, list[PlotConfig]]
class MonitorConfigValidator:
"""Validates the configuration data for the BECMonitor."""
devices = None
def __init__(self, devices):
# self.device_manager = device_manager
MonitorConfigValidator.devices = devices
def validate_monitor_config(
self, config_data: dict
) -> Union[DeviceMonitorConfig, ScanModeConfig]:
"""
Validates the configuration data based on the provided schema.
Args:
config_data (dict): Configuration data to be validated.
Returns:
Union[DeviceMonitorConfig, ScanModeConfig]: Validated configuration object.
Raises:
ValidationError: If the configuration data does not conform to the schema.
"""
config_type = config_data.get("plot_settings", {}).get("scan_types", False)
if config_type:
validated_config = ScanModeConfig(**config_data)
else:
validated_config = DeviceMonitorConfig(**config_data)
return validated_config

View File

@@ -1,12 +1 @@
from .figure import BECFigure, FigureConfig
from .monitor import BECMonitor
from .motor_control import (
MotorControlAbsolute,
MotorControlRelative,
MotorControlSelection,
MotorCoordinateTable,
MotorThread,
)
from .motor_map import MotorMap
from .plots import BECCurve, BECMotorMap, BECWaveform
from .scan_control import ScanControl

View File

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

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QHBoxLayout, QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
class BECQueue(BECWidget, QWidget):
"""
Widget to display the BEC queue.
"""
ICON_NAME = "edit_note"
def __init__(
self,
parent: QWidget | None = None,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
):
super().__init__(client, config, gui_id)
QWidget.__init__(self, parent=parent)
self.table = QTableWidget(self)
self.layout = QHBoxLayout(self)
self.layout.addWidget(self.table)
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
self.reset_content()
@Slot(dict, dict)
def update_queue(self, content, _metadata):
"""
Update the queue table with the latest queue information.
Args:
content (dict): The queue content.
_metadata (dict): The metadata.
"""
# only show the primary queue for now
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
self.table.setRowCount(len(queue_info))
self.table.clearContents()
if not queue_info:
self.reset_content()
return
for index, item in enumerate(queue_info):
blocks = item.get("request_blocks", [])
scan_types = []
scan_numbers = []
status = item.get("status", "")
for request_block in blocks:
scan_type = request_block.get("content", {}).get("scan_type", "")
if scan_type:
scan_types.append(scan_type)
scan_number = request_block.get("scan_number", "")
if scan_number:
scan_numbers.append(str(scan_number))
if scan_types:
scan_types = ", ".join(scan_types)
if scan_numbers:
scan_numbers = ", ".join(scan_numbers)
self.set_row(index, scan_numbers, scan_types, status)
def format_item(self, content: str) -> QTableWidgetItem:
"""
Format the content of the table item.
Args:
content (str): The content to be formatted.
Returns:
QTableWidgetItem: The formatted item.
"""
if not content or not isinstance(content, str):
content = ""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
return item
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
"""
Set the row of the table.
Args:
index (int): The index of the row.
scan_number (str): The scan number.
scan_type (str): The scan type.
status (str): The status.
"""
self.table.setItem(index, 0, self.format_item(scan_number))
self.table.setItem(index, 1, self.format_item(scan_type))
self.table.setItem(index, 2, self.format_item(status))
def reset_content(self):
"""
Reset the content of the table.
"""
self.table.setRowCount(1)
self.set_row(0, "", "", "")
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECQueue()
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1 @@
{'files': ['bec_queue.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.bec_queue.bec_queue import BECQueue
DOM_XML = """
<ui language='c++'>
<widget class='BECQueue' name='bec_queue'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECQueue(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(BECQueue.ICON_NAME)
def includeFile(self):
return "bec_queue"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECQueue"
def toolTip(self):
return "Widget to display the BEC queue."
def whatsThis(self):
return self.toolTip()

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_queue.bec_queue_plugin import BECQueuePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECQueuePlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,319 @@
"""This module contains the BECStatusBox widget, which displays the status of different BEC services in a collapsible tree widget.
The widget automatically updates the status of all running BEC services, and displays their status.
"""
from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.utils.import_utils import lazy_import_from
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
from bec_lib.client import BECClient
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
@dataclass
class BECServiceInfoContainer:
"""Container to store information about the BEC services."""
service_name: str
status: str
info: dict
metrics: dict | None
class BECServiceStatusMixin(QObject):
"""Mixin to receive the latest service status from the BEC server and emit it via services_update signal.
Args:
client (BECClient): The client object to connect to the BEC server.
"""
services_update = Signal(dict, dict)
ICON_NAME = "dns"
def __init__(self, parent, client: BECClient):
super().__init__(parent)
self.client = client
self._service_update_timer = QTimer()
self._service_update_timer.timeout.connect(self._get_service_status)
self._service_update_timer.start(1000)
def _get_service_status(self):
"""Get the latest service status from the BEC server."""
# pylint: disable=protected-access
self.client._update_existing_services()
self.services_update.emit(self.client._services_info, self.client._services_metric)
def cleanup(self):
"""Cleanup the BECServiceStatusMixin."""
self._service_update_timer.stop()
self._service_update_timer.deleteLater()
class BECStatusBox(BECWidget, QWidget):
"""An autonomous widget to display the status of BEC services.
Args:
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
box_name Optional(str): The name of the top service label. Defaults to "BEC Server".
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
gui_id Optional(str): The unique id for the widget. Defaults to None.
"""
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(BECServiceInfoContainer)
bec_core_state = Signal(str)
def __init__(
self,
parent=None,
box_name: str = "BEC Server",
client: BECClient = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.tree = QTreeWidget(self)
self.layout = QHBoxLayout(self)
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
if not bec_service_status_mixin:
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
self.bec_service_status = bec_service_status_mixin
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
self.layout.addWidget(self.tree)
def init_ui(self) -> None:
"""Init the UI for the BECStatusBox widget, should only take place once."""
self.init_ui_tree_widget()
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
tree_item = QTreeWidgetItem()
tree_item.setExpanded(True)
tree_item.setDisabled(True)
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
self.tree.addTopLevelItem(tree_item)
self.tree.setItemWidget(tree_item, 0, top_label)
self.service_update.connect(top_label.update_config)
self._initialized = True
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
self.tree.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
information about the service in the status_container.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info Optional(dict): The information about the service. Default is {}
metric Optional(dict): Metrics for the respective service. Default is None
Returns:
StatusItem: The status item widget.
"""
if info is None:
info = {}
self._update_status_container(service_name, status, info, metrics)
item = StatusItem(parent=self, config=self.status_container[service_name]["info"])
return item
@Slot(str)
def update_top_item_status(self, status: BECStatus) -> None:
"""Method to update the status of the top item in the tree widget.
Gets the status from the Signal 'bec_core_state' and updates the StatusItem via the signal 'service_update'.
Args:
status (BECStatus): The state of the core services.
"""
self.status_container[self.box_name]["info"].status = status
self.service_update.emit(self.status_container[self.box_name]["info"])
def _update_status_container(
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
) -> None:
"""Update the status_container with the newest status and metrics for the BEC service.
If information about the service already exists, it will create a new entry.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info (dict): The information about the service.
metrics (dict): The metrics of the service.
"""
container = self.status_container[service_name].get("info", None)
if container:
container.status = status.name
container.info = info
container.metrics = metrics
return
service_info_item = BECServiceInfoContainer(
service_name=service_name,
status=status.name if isinstance(status, BECStatus) else status,
info=info,
metrics=metrics,
)
self.status_container[service_name].update({"info": service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
"""Callback function services_metric from BECServiceStatusMixin.
It updates the status of all services.
Args:
services_info (dict): A dictionary containing the service status for all running BEC services.
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
"""
checked = [self.box_name]
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)
for service_name, msg in sorted(services_info.items()):
checked.append(service_name)
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name in self.status_container:
if not msg:
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
continue
self._update_status_container(service_name, msg.status, msg.info, metrics)
self.service_update.emit(self.status_container[service_name]["info"])
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self.check_redundant_tree_items(checked)
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
"""Update the core services of BEC, and emit the updated status to the BECStatusBox.
Args:
services_info (dict): A dictionary containing the service status of different services.
services_metric (dict): A dictionary containing the service metrics of different services.
Returns:
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
"""
core_state = BECStatus.RUNNING
for service_name in sorted(self.CORE_SERVICES):
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
msg = services_info.pop(service_name, None)
if service_name not in self.status_container:
if not msg:
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
continue
if not msg:
self.status_container[service_name]["info"].status = "NOTCONNECTED"
core_state = None
else:
self._update_status_container(service_name, msg.status, msg.info, metrics)
if core_state:
core_state = msg.status if msg.status.value < core_state.value else core_state
self.service_update.emit(self.status_container[service_name]["info"])
self.bec_core_state.emit(core_state.name if core_state else "NOTCONNECTED")
return services_info
def check_redundant_tree_items(self, checked: list) -> None:
"""Utility method to check and remove redundant objects from the BECStatusBox.
Args:
checked (list): A list of services that are currently running.
"""
to_be_deleted = [key for key in self.status_container if key not in checked]
for key in to_be_deleted:
obj = self.status_container.pop(key)
item = obj["item"]
self.status_container[self.box_name]["item"].removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
) -> None:
"""Method to add a new QTreeWidgetItem together with a StatusItem to the tree widget.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info (dict): The information about the service.
metrics (dict): The metrics of the service.
"""
item_widget = self._create_status_widget(service_name, status, info, metrics)
item = QTreeWidgetItem()
self.service_update.connect(item_widget.update_config)
self.status_container[self.box_name]["item"].addChild(item)
self.tree.setItemWidget(item, 0, item_widget)
self.status_container[service_name].update({"item": item, "widget": item_widget})
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Callback function for double clicks on individual QTreeWidgetItems in the collapsed section.
Args:
item (QTreeWidgetItem): The item that was double clicked.
column (int): The column that was double clicked.
"""
for _, objects in self.status_container.items():
if objects["item"] == item:
objects["widget"].show_popup()
def cleanup(self):
"""Cleanup the BECStatusBox widget."""
self.bec_service_status.cleanup()
return super().cleanup()
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
{'files': ['bec_status_box.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.bec_status_box.bec_status_box import BECStatusBox
DOM_XML = """
<ui language='c++'>
<widget class='BECStatusBox' name='bec_status_box'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECStatusBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(BECStatusBox.ICON_NAME)
def includeFile(self):
return "bec_status_box"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECStatusBox"
def toolTip(self):
return "An autonomous widget to display the status of BEC services."
def whatsThis(self):
return self.toolTip()

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_status_box.bec_status_box_plugin import BECStatusBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,144 @@
""" Module for a StatusItem widget to display status and metrics for a BEC service.
The widget is bound to be used with the BECStatusBox widget."""
import enum
import os
from datetime import datetime
from bec_lib.utils.import_utils import lazy_import_from
from qtpy.QtCore import Qt, Slot
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout, QWidget
import bec_widgets
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = os.path.join(MODULE_PATH, "assets", "status_icons", "running.svg")
BUSY = os.path.join(MODULE_PATH, "assets", "status_icons", "refresh.svg")
IDLE = os.path.join(MODULE_PATH, "assets", "status_icons", "warning.svg")
ERROR = os.path.join(MODULE_PATH, "assets", "status_icons", "error.svg")
NOTCONNECTED = os.path.join(MODULE_PATH, "assets", "status_icons", "not_connected.svg")
class StatusItem(QWidget):
"""A widget to display the status of a service.
Args:
parent: The parent widget.
config (dict): The configuration for the service, must be a BECServiceInfoContainer.
"""
def __init__(self, parent: QWidget = None, config=None):
QWidget.__init__(self, parent=parent)
if config is None:
# needed because we need parent to be the first argument for QT Designer
raise ValueError(
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
)
self.config = config
self.parent = parent
self.layout = None
self._label = None
self._icon = None
self.icon_size = (24, 24)
self._popup_label_ref = {}
self.init_ui()
def init_ui(self) -> None:
"""Init the UI for the status item widget."""
self.layout = QHBoxLayout()
self.layout.setContentsMargins(5, 5, 5, 5)
self.setLayout(self.layout)
self._label = QLabel()
self._icon = QLabel()
self.layout.addWidget(self._label)
self.layout.addWidget(self._icon)
self.update_ui()
@Slot(dict)
def update_config(self, config) -> None:
"""Update the config of the status item widget.
Args:
config (dict): Config updates from parent widget, must be a BECServiceInfoContainer.
"""
if self.config is None or config.service_name != self.config.service_name:
return
self.config = config
self.update_ui()
def update_ui(self) -> None:
"""Update the UI of the labels, and popup dialog."""
if self.config is None:
return
self.set_text()
self.set_status()
self._set_popup_text()
def set_text(self) -> None:
"""Set the text of the QLabel basae on the config."""
service = self.config.service_name
status = self.config.status
if len(service.split("/")) > 1 and service.split("/")[0].startswith("BEC"):
service = service.split("/", maxsplit=1)[0] + "/..." + service.split("/")[1][-4:]
if status == "NOTCONNECTED":
status = "NOT CONNECTED"
text = f"{service} is {status}"
self._label.setText(text)
def set_status(self) -> None:
"""Set the status icon for the status item widget."""
icon_path = IconsEnum[self.config.status].value
icon = QIcon(icon_path)
self._icon.setPixmap(icon.pixmap(*self.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
def show_popup(self) -> None:
"""Method that is invoked when the user double clicks on the StatusItem widget."""
dialog = QDialog(self)
dialog.setWindowTitle(f"{self.config.service_name} Details")
layout = QVBoxLayout()
popup_label = self._make_popup_label()
self._set_popup_text()
layout.addWidget(popup_label)
dialog.setLayout(layout)
dialog.finished.connect(self._cleanup_popup_label)
dialog.exec()
def _make_popup_label(self) -> QLabel:
"""Create a QLabel for the popup dialog.
Returns:
QLabel: The label for the popup dialog.
"""
label = QLabel()
label.setWordWrap(True)
self._popup_label_ref.update({"label": label})
return label
def _set_popup_text(self) -> None:
"""Compile the metrics text for the status item widget."""
if self._popup_label_ref.get("label") is None:
return
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":
value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
metrics_text += f"<b>{key.upper()}:</b> {value}<br>"
self._popup_label_ref["label"].setText(metrics_text)
def _cleanup_popup_label(self) -> None:
"""Cleanup the popup label."""
self._popup_label_ref.clear()

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Literal
import pyqtgraph as pg
class ColorButton(pg.ColorButton):
"""
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
Patches event loop of the ColorDialog, if opened in another QDialog.
"""
ICON_NAME = "colors"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def selectColor(self):
self.origColor = self.color()
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
"""
Get the color of the button in the specified format.
Args:
format(Literal["RGBA", "HEX"]): The format of the returned color.
Returns:
tuple|str: The color in the specified format.
"""
if format == "RGBA":
return self.color().getRgb()
if format == "HEX":
return self.color().name()
def cleanup(self):
"""
Clean up the ColorButton.
"""
self.colorDialog.close()
self.colorDialog.deleteLater()

View File

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

View File

@@ -0,0 +1,55 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.color_button.color_button import ColorButton
DOM_XML = """
<ui language='c++'>
<widget class='ColorButton' name='color_button'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColorButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColorButton.ICON_NAME)
def includeFile(self):
return "color_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ColorButton"
def toolTip(self):
return "ColorButton which opens a color dialog."
def whatsThis(self):
return self.toolTip()

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.color_button.color_button_plugin import ColorButtonPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
import pyqtgraph as pg
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtGui import QColor, QFontMetrics, QImage
from qtpy.QtWidgets import QApplication, QComboBox, QStyledItemDelegate, QVBoxLayout, QWidget
class ColormapDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super(ColormapDelegate, self).__init__(parent)
self.image_width = 25
self.image_height = 10
self.gap = 10
def paint(self, painter, option, index):
text = index.data()
colormap = pg.colormap.get(text)
colors = colormap.getLookupTable(start=0.0, stop=1.0, alpha=False)
font_metrics = QFontMetrics(painter.font())
text_width = font_metrics.width(text)
text_height = font_metrics.height()
total_height = max(text_height, self.image_height)
image = QImage(self.image_width, self.image_height, QImage.Format_RGB32)
for i in range(self.image_width):
color = QColor(*colors[int(i * (len(colors) - 1) / (self.image_width - 1))])
for j in range(self.image_height):
image.setPixel(i, j, color.rgb())
painter.drawImage(
option.rect.x(), option.rect.y() + (total_height - self.image_height) // 2, image
)
painter.drawText(
option.rect.x() + self.image_width + self.gap,
option.rect.y() + (total_height - text_height) // 2 + font_metrics.ascent(),
text,
)
class ColormapSelector(QWidget):
"""
Simple colormap combobox widget. By default it loads all the available colormaps in pyqtgraph.
"""
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
def __init__(self, parent=None, default_colormaps=None):
super().__init__(parent=parent)
self._colormaps = []
self.initUI(default_colormaps)
def initUI(self, default_colormaps=None):
self.layout = QVBoxLayout(self)
self.combo = QComboBox()
self.combo.setItemDelegate(ColormapDelegate())
self.combo.currentTextChanged.connect(self.colormap_changed)
self.available_colormaps = pg.colormap.listMaps()
if default_colormaps is None:
default_colormaps = self.available_colormaps
self.add_color_maps(default_colormaps)
self.layout.addWidget(self.combo)
@Slot()
def colormap_changed(self):
"""
Emit the colormap changed signal with the current colormap selected in the combobox.
"""
self.colormap_changed_signal.emit(self.combo.currentText())
def add_color_maps(self, colormaps=None):
"""
Add colormaps to the combobox.
Args:
colormaps(list): List of colormaps to add to the combobox. If None, all available colormaps are added.
"""
self.combo.clear()
if colormaps is not None:
for name in colormaps:
if name in self.available_colormaps:
self.combo.addItem(name)
else:
for name in self.available_colormaps:
self.combo.addItem(name)
self._colormaps = colormaps if colormaps is not None else self.available_colormaps
@Property("QStringList")
def colormaps(self):
"""
Property to get and set the colormaps in the combobox.
"""
return self._colormaps
@colormaps.setter
def colormaps(self, value):
"""
Set the colormaps in the combobox.
"""
if self._colormaps != value:
self._colormaps = value
self.add_color_maps(value)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
ex = ColormapSelector()
ex.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1 @@
{'files': ['colormap_selector.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.colormap_selector.colormap_selector import ColormapSelector
DOM_XML = """
<ui language='c++'>
<widget class='ColormapSelector' name='colormap_selector'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ColormapSelectorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColormapSelector(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColormapSelector.ICON_NAME)
def includeFile(self):
return "colormap_selector"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ColormapSelector"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

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