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

Compare commits

..

273 Commits

Author SHA1 Message Date
9076a1731e wip disable logger warning when there is async update and no scan running 2025-05-30 16:57:04 +02:00
d3f2c3bd6b fix(waveform): waveform only update async data when scan is currently running 2025-05-30 13:25:16 +02:00
semantic-release
f46ffb14e1 2.9.0
Automatically generated by python-semantic-release
2025-05-30 11:14:35 +00:00
2b9919bb34 docs: add usage docs for signal label widget 2025-05-30 13:13:55 +02:00
822e7d06ff feat: (#569) add signal label widget
add a widget which shows the current value of a signal from BEC.
configurable with many properties in designer. intended for use mainly
in static GUIs.
2025-05-30 13:13:55 +02:00
91195ae0fd fix(DeviceSignalInput): improve robustness
use set for storing filter properties to allow multiple set to true or
false
2025-05-30 13:13:55 +02:00
a6c5c21afa style: typing in bec_dispatcher 2025-05-30 13:13:55 +02:00
semantic-release
ff06954cb7 2.8.4
Automatically generated by python-semantic-release
2025-05-30 11:01:06 +00:00
c8128faf79 fix(crosshair): label decimal precision is dynamically scaled with the plot zoom; API of all affected widgets adjusted; option added to PlotBase; closes #637 2025-05-30 13:00:18 +02:00
semantic-release
6b65a94c81 2.8.3
Automatically generated by python-semantic-release
2025-05-30 09:03:15 +00:00
bf172b8431 fix: guard plugin repo import in e2e test 2025-05-30 11:02:14 +02:00
05329ab50f test(e2e): add tests involving plugin repo 2025-05-28 20:39:51 +02:00
b225a7cc90 refactor: store modules with widget search 2025-05-28 13:05:28 +02:00
semantic-release
3d8af05688 2.8.2
Automatically generated by python-semantic-release
2025-05-27 14:44:05 +00:00
0bdd4e86a2 fix(image_roi): rois are invertible by default, fixes resizing bug when adding from ROI manager 2025-05-27 16:43:22 +02:00
semantic-release
104e4e427b 2.8.1
Automatically generated by python-semantic-release
2025-05-27 14:34:15 +00:00
ada0977a1b fix(launch_window): font and tile size fixed across OSs, closes #607 2025-05-27 16:33:36 +02:00
semantic-release
1ea467c5fc 2.8.0
Automatically generated by python-semantic-release
2025-05-26 13:05:43 +00:00
4f69f5da45 refactor(toolbar): add warning if no parent is provided as it may lead to segfaults 2025-05-26 15:05:06 +02:00
d8547c7a56 fix(ImageProcessing): use target widget as parent 2025-05-26 15:05:06 +02:00
3484507c75 feat(plot_base): add option to specify units 2025-05-26 15:05:06 +02:00
8abebb7286 refactor(server): minor cleanup of imports 2025-05-26 15:05:06 +02:00
semantic-release
1d07e88b44 2.7.1
Automatically generated by python-semantic-release
2025-05-26 12:38:59 +00:00
1a4eb1db67 fix(signal-combobox): bug fix in signal combobox that crashed upon switching from device to signal input 2025-05-26 14:38:19 +02:00
f57950c4e3 test(input-widgets): add e2e tests to test widget inputs with demo config of bec. 2025-05-26 14:38:19 +02:00
a8811c9d91 refactor: add rpc interface to signal_line_edit/combobox; add user access methods 2025-05-26 14:38:19 +02:00
ec740d31fd fix(signal-line-edit): fix signal_line_edit validity check; closes #610 2025-05-26 14:38:19 +02:00
semantic-release
5c12ab1992 2.7.0
Automatically generated by python-semantic-release
2025-05-26 12:23:35 +00:00
ce88787e88 feat(image): roi plots with crosshair cuts added 2025-05-26 14:22:51 +02:00
e12e9e534d fix(image/image_selecetion): toolbar selection tool size adjusted 2025-05-26 14:22:51 +02:00
66e9445760 fix(plot_base/mouse_interactions.py): fixed parent 2025-05-26 14:22:51 +02:00
semantic-release
6bf4c53805 2.6.0
Automatically generated by python-semantic-release
2025-05-26 11:14:14 +00:00
a939c3b1c4 feat(image_roi_tree): gui roi manager for image widget 2025-05-26 13:13:31 +02:00
41b7ca8e64 fix(image_roi): position can be set from rpc 2025-05-26 13:13:31 +02:00
7a531c17d6 refactor(image_roi): glowing handles for Rectangle roi 2025-05-26 13:13:31 +02:00
a020f2dc7e feat(waveform): LMFitDialog cleanup after close 2025-05-26 13:13:31 +02:00
53377d26e2 ci: add pr issue sync 2025-05-23 17:27:54 +02:00
05489a1c56 chore: migrate issue template to github form syntax 2025-05-22 15:48:10 +02:00
semantic-release
0dfff71e4a 2.5.4
Automatically generated by python-semantic-release
2025-05-22 10:48:11 +00:00
d4def09a4e fix(dock_area): menu to add LogPanel into DockArea is temporary disabled 2025-05-22 12:47:21 +02:00
semantic-release
713653a4a5 2.5.3
Automatically generated by python-semantic-release
2025-05-22 09:59:18 +00:00
bcab66b187 fix(server): SimpleFileLikeFromLogOutputFunc added encoding for stdout 2025-05-22 11:58:30 +02:00
a345253c6e ci: reusable actions for installing bec widgets 2025-05-22 09:10:47 +02:00
semantic-release
bdf33a5249 2.5.2
Automatically generated by python-semantic-release
2025-05-22 07:07:24 +00:00
f8276f0224 fix: update gitignore 2025-05-22 09:06:43 +02:00
8227c44c33 docs: fix build process for sphinx 2025-05-21 14:21:16 +02:00
semantic-release
83098d930c 2.5.1
Automatically generated by python-semantic-release
2025-05-21 11:14:04 +00:00
a7ae856c8f fix(ui loader): fix loader for widget plugins 2025-05-21 13:13:18 +02:00
Klaus Wakonig
06f43e4883 docs: add kwargs to example 2025-05-21 09:29:24 +02:00
Klaus Wakonig
5ec9697271 docs(developer): fix hello world example 2025-05-21 09:29:24 +02:00
semantic-release
41296b5471 2.5.0
Automatically generated by python-semantic-release
2025-05-20 14:37:27 +00:00
1d018e863c feat(image_rois): image rois with RPC can be added to Image widget 2025-05-20 16:36:48 +02:00
6ee0f5004d ci: try uv for test env setup 2025-05-20 15:05:06 +02:00
semantic-release
40b5081632 2.4.3
Automatically generated by python-semantic-release
2025-05-19 15:25:35 +00:00
f064baae68 fix: twine upload key 2025-05-19 17:24:55 +02:00
semantic-release
58f01fb3a2 2.4.2
Automatically generated by python-semantic-release
2025-05-19 15:04:48 +00:00
1e344eacb7 fix: push release using GH_token 2025-05-19 17:04:04 +02:00
semantic-release
34002fa51a 2.4.1
Automatically generated by python-semantic-release
2025-05-19 14:34:58 +00:00
a00d510a75 fix: skip actions on new tags 2025-05-19 16:34:19 +02:00
semantic-release
120faf9523 2.4.0
Automatically generated by python-semantic-release
2025-05-19 13:53:08 +00:00
d7bd61f69e ci: use custom semver action 2025-05-19 15:50:24 +02:00
94bcfff724 ci: add known hosts 2025-05-19 15:10:38 +02:00
a17e7a0d52 ci: add deploy ssh key to release job 2025-05-19 15:02:54 +02:00
7f67d28887 ci: use ssh key for push 2025-05-19 14:39:01 +02:00
52d8e4b332 ci: build with ssh key 2025-05-19 14:26:17 +02:00
dea2b44e6a ci: fix job permissions for release 2025-05-19 13:47:25 +02:00
dc70ea6dfb ci: fix missing build dependencies 2025-05-19 13:32:16 +02:00
133ddda3e3 ci: fix missing build dependencies 2025-05-19 13:16:29 +02:00
8eee92e5cf ci: add semantic-release job 2025-05-19 12:57:17 +02:00
Klaus Wakonig
85de24aa89 chore: update issue templates 2025-05-17 20:38:32 +02:00
56b6a0b8c2 feat: add web console 2025-05-17 13:34:21 +02:00
d579d894f0 feat(modular_toolbar): remove action/bundle by id 2025-05-17 09:55:00 +02:00
d915d2f507 fix: (#612) fix additional MD form
makes sure the form is validated on any changes of the additional
metadata table model so that they are propagated to the scan control
widget even when nothing is entered in the standard form
2025-05-16 14:37:07 +02:00
7d7a88669f fix: (#572) signal input base filter
use name attribute rather than value from Kind, to compare with kind_str
2025-05-16 10:50:27 +02:00
a42dcec6d4 fix(entry_validator): device signals retrieved from ._info instead of .describe(), close #570 2025-05-15 15:33:00 +02:00
8cf1f09926 ci: exclude test dir from coverage report 2025-05-15 11:35:45 +02:00
83b153a14a ci: include lines with >=3 characters in report 2025-05-15 09:52:37 +02:00
aed450ef2c fix(side_panel): side panel can be open without icon; toolbar can be hidden if not needed 2025-05-15 08:20:19 +02:00
e60d0cb5ca ci: add generate-cli test 2025-05-14 23:37:15 +02:00
01870f9cda test: coverage report settings 2025-05-14 17:43:39 +02:00
483886495d ci: tidy workflow names 2025-05-14 17:43:39 +02:00
42502f6eed ci: only run tests if formatter passes 2025-05-14 17:43:39 +02:00
59d87e1c2f ci: no cov report with failed tests 2025-05-14 17:43:39 +02:00
Klaus Wakonig
3a5fa3d01a chore: update license 2025-05-14 16:36:03 +02:00
dbb3a1c1fb fix(workflows): update ophyd_devices clone URL to use GitHub 2025-05-14 16:15:06 +02:00
ca8211572f ci(workflows): update git clone URL for BEC repository to use GitHub 2025-05-14 15:41:12 +02:00
7584af4e44 ci: don't duplicate push & PR 2025-05-14 15:08:47 +02:00
95ef26565b ci: add codecov upload
and remove other coverage solution
2025-05-14 15:08:47 +02:00
abbf7a7f44 fix(device_input): remove unnecessary lowercase conversion for device selection 2025-05-14 11:58:41 +02:00
a301d37c4f ci: coverage 2025-05-13 19:29:11 +02:00
88a17a566c fix(layout_manager): adding relative widget is shifting whole column to not destroy previous layout 2025-05-13 11:33:16 +02:00
bf3746da0e refactor(color_button_native): color button with OS native dialog separated from the curve tree 2025-05-12 18:24:46 +02:00
e3205d6c97 ci: fix upload to codecov 2025-05-12 18:23:23 +02:00
Klaus Wakonig
507ac10e8d ci: add links to badges
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-12 18:23:23 +02:00
16e167019f ci: add coverage report 2025-05-12 18:23:23 +02:00
d712944e6b docs: badges extravaganza 2025-05-12 18:23:23 +02:00
d9b60c6cc9 docs: fix license reference 2025-05-12 15:37:10 +02:00
aee83e1a9e docs: add badge for code style, version and license 2025-05-12 15:37:10 +02:00
f5317341bf ci: add ci status badge 2025-05-12 15:37:10 +02:00
8345dacb26 ci: add github workflows 2025-05-12 13:44:37 +02:00
semantic-release
531d9c621d 2.3.0
Automatically generated by python-semantic-release
2025-05-09 12:36:13 +00:00
dc151cdfe3 feat(bec_connector): ability to change object name during runtime 2025-05-09 14:27:44 +02:00
semantic-release
e0dfd56a0d 2.2.0
Automatically generated by python-semantic-release
2025-05-09 09:41:37 +00:00
1fb680abb4 feat(launcher): add support for launching plugin widget 2025-05-08 17:30:16 +02:00
b9e56c96cb refactor(launch_window): widget tile added 2025-05-08 13:50:01 +02:00
semantic-release
dd956f18fe 2.1.3
Automatically generated by python-semantic-release
2025-05-07 14:31:53 +00:00
cf59d31113 fix(bec-dispatcher): fix reference to boundmethods to avoid duplicated subscriptions 2025-05-07 11:08:06 +02:00
semantic-release
bc0e277332 2.1.2
Automatically generated by python-semantic-release
2025-05-06 11:09:41 +00:00
75a2780fe0 tests(user-interaction-e2e): add module scoped e2e tests with user interaction; closes #508 2025-05-06 11:28:12 +02:00
a6c479e42e build: remove flush-redis from ci job 2025-05-06 11:28:12 +02:00
64a4824054 fix(waveform): Ignore callbacks for on_async_readback from QtSender objects that are already destroyed; closes #497 2025-05-06 11:28:12 +02:00
1619446ec9 refactor(bec-status-box): add get_server_state user_access method to BECStatusBox 2025-05-06 11:28:12 +02:00
37f002427a refactor(bec-progressbar): add private method for bec_progressbar, udate client file 2025-05-06 11:28:12 +02:00
semantic-release
50cb70dcc6 2.1.1
Automatically generated by python-semantic-release
2025-05-06 08:37:48 +00:00
55f7efc4f5 fix: import add operator in client 2025-05-06 10:20:47 +02:00
be72c9f270 refactor: supply bec designer filename to function 2025-05-06 10:20:47 +02:00
c8cedc0124 wip 2025-05-06 08:54:36 +02:00
semantic-release
3fdbe4031e 2.1.0
Automatically generated by python-semantic-release
2025-05-05 11:10:40 +00:00
c16b9dce9c test(Dock): add validation for new dock creation with invalid name 2025-05-05 13:01:21 +02:00
9387275851 feat(SafeSlot): slot parameters can be overridden with kwarg; add option to raise 2025-05-05 13:01:21 +02:00
94463afdba fix: ensure rpc object do not collide with protected names 2025-05-05 13:01:21 +02:00
02563b10f3 refactor(colormap_widget): widget is rounded 2025-05-02 16:01:51 +02:00
fff4af2489 ci: install dev dependencies for formatter 2025-05-02 14:12:18 +02:00
452124b528 chore(formatter): upgrade to black v25 2025-05-02 14:12:18 +02:00
semantic-release
9c84e158ba 2.0.3
Automatically generated by python-semantic-release
2025-05-02 11:52:31 +00:00
58a0bc7974 fix(image_item): wrong user access name for rotation 2025-05-02 12:23:16 +02:00
770dbd4b63 fix(generate_cli): apply isort config 2025-05-02 12:23:16 +02:00
d22035f897 ci: add job to test that the generated client is up to date 2025-05-02 11:24:38 +02:00
semantic-release
fe21b39b7f 2.0.2
Automatically generated by python-semantic-release
2025-05-01 11:00:33 +00:00
1b78840fd8 fix(plot_base): no content margin for plot_widget window 2025-05-01 12:02:47 +02:00
semantic-release
46519342b6 2.0.1
Automatically generated by python-semantic-release
2025-04-30 11:52:51 +00:00
9079ddd727 fix(dock_area): restore state safeguard to not pass none to pyqtgraph restoreState 2025-04-30 13:14:16 +02:00
semantic-release
205745cc72 2.0.0
Automatically generated by python-semantic-release
2025-04-29 17:22:28 +00:00
717017e69e doc(image): rotation update 2025-04-29 19:06:47 +02:00
a3de1f0a31 refactor(plots): waveform and image rpc api review 2025-04-29 18:37:53 +02:00
8eef4253b0 feat(slot): add 'verify_sender' argument to SafeSlot for sender verification 2025-04-29 17:49:01 +02:00
1f2db927f5 fix(scan_control): restore scan parameters always regenerate the arg box, preventing infinite loop 2025-04-29 17:38:56 +02:00
98f159b25f fix(image): ImageItem remove adjusted to disconnect and remove current displayed image 2025-04-29 16:31:11 +02:00
061f3481da fix(becconnector): widgets can be flagged as root widget, skipping the BECMainWindow in CLI usage 2025-04-29 16:16:35 +02:00
f35f4c4b29 fix(becconnector,widgets): parent_id is always fetched from the real bec widget parent; all widgets adjusted; hardcoded parent_ids removed 2025-04-29 13:23:09 +02:00
c36852b2ef fix(rpc_server): broadcasted data check 2025-04-29 11:48:35 +02:00
4eaadd1545 fix(scan_matadata): parent passing 2025-04-29 11:35:10 +02:00
David Perl
d04770fe91 refactor: rearrange base of metadata forms for generic use 2025-04-29 11:35:10 +02:00
23fee22ef8 test: fix tests for launcher close / hide behavior 2025-04-29 10:09:47 +02:00
6e7920c119 fix(launcher): hide launcher when launcher is closed even though it is not the last widget 2025-04-29 09:43:19 +02:00
e3d0d5566c test: add IPython client GUI object test module with tab completion 2025-04-28 15:38:50 +02:00
e5b532274e refactor(assets): new icon for ui loader 2025-04-28 14:20:42 +02:00
eb0323b989 build(dependencies): update min bec_lib version to 3.29 2025-04-28 08:39:05 +02:00
60852e228f docs: replaces instances of QtDesigner with BEC Designer for improved clarity 2025-04-27 16:58:40 +02:00
b3dbe922de fix(launch_window): return None when cancelling the ui file launcher 2025-04-27 13:50:43 +02:00
fde912005d fix(cleanup): prevent double cleanup by tracking object destruction state 2025-04-27 13:45:58 +02:00
5e4965fe1f docs(lmfit): fix links 2025-04-25 20:29:26 +02:00
aff5a51f4c fix(type hints): add future import to prevent sphinx from crashing 2025-04-25 20:29:26 +02:00
b4af2cc77a docs: updated docs for v2 (#531) 2025-04-25 20:29:26 +02:00
25bd905cef docs: update docs for v2 2025-04-25 20:08:21 +02:00
2f0d213e32 docs(position-indicator): update docs for positioner indicator 2025-04-25 19:41:20 +02:00
b6695b45d0 docs: update docs for various widgets 2025-04-25 19:41:20 +02:00
77f9d42576 fix: unique name for widgets, fix new method for docks; closes #534 2025-04-25 19:41:20 +02:00
8cca510fa1 fix(client): import reduce 2025-04-25 16:59:53 +02:00
06a4954d3d fix(BECGuiClient): add launch_script parameter to dock area creation 2025-04-24 17:39:55 +02:00
4acf5befb1 docs: review quick_start 2025-04-24 14:38:07 +02:00
99d76236ca test: add tests for name creation of custom curves, and object name handling 2025-04-24 08:49:33 +02:00
afc818bf7d docs: update quick_start 2025-04-24 08:49:33 +02:00
8e846d4499 fix(curve): fix unique names for custom curves 2025-04-24 08:49:33 +02:00
a1c859c743 docs: remove BECFigure from docs, fix wrong api for docs of plotting widgets 2025-04-24 08:49:33 +02:00
75cc45d767 docs: remove BECFigure 2025-04-24 08:49:33 +02:00
1d091071e1 fix: bugfix in cleanup of ScatterWaveform ScatterCurve; closes #520 2025-04-24 08:49:33 +02:00
8e64b65c2d feat: delete bec_app 2025-04-24 08:49:33 +02:00
27ea92d120 feat: deprecated and delete alignment_1d gui 2025-04-24 08:49:33 +02:00
3ddfeaa49f fix(serialization): add serialization for qpointf 2025-04-23 20:42:54 +02:00
074bbbc166 fix: change default colormap to plasma 2025-04-23 19:05:54 +02:00
3709cdc866 fix(bec_connector): improve cleanup handling on deleted parent to prevent errors 2025-04-23 17:45:58 +02:00
9d6d0b406a refactor(bec_connector): replace pyqtSlot with SafeSlot for consistency 2025-04-23 17:45:58 +02:00
6318b2d822 fix(designer-plugin-generator): enhance super constructor validation for new style classes 2025-04-23 17:45:58 +02:00
f89e74b199 refactor: add template for debugging the cli generator 2025-04-23 17:45:58 +02:00
0ac14a74b8 fix: ensure provided dock and dock_area names are valid and defaults are snake_case 2025-04-23 16:22:13 +02:00
1910993b2b fix(positioner-indicator): fix property setters for position indicator 2025-04-23 14:00:06 +02:00
7c303d0129 fix(ring-progress-bar): fix bug in disconnect slot of rings, enable 'scan' mode as default for init with first ring 2025-04-23 07:30:07 +02:00
113938e71a test: fix rpc widgets e2e test 2025-04-22 21:19:37 +02:00
e0f146beeb fix(compact_popup): forward close event 2025-04-22 21:19:37 +02:00
fc1cdc814f fix(bec_connector): call cleanup on widgets if the parent was deleted 2025-04-22 21:19:37 +02:00
a13de45131 fix(rpc): call close on container widget if needed 2025-04-22 21:19:37 +02:00
8ff2063bc8 fix: proper cleanup of progressbar 2025-04-22 21:19:37 +02:00
cdc613b6e7 fix(bec_queue): set parent for toolbar buttons 2025-04-22 21:19:37 +02:00
1fc6125369 fix: forward parent to children 2025-04-22 21:19:37 +02:00
fef07ac8e1 fix: import from qtpy instead of PySide6 2025-04-22 21:19:37 +02:00
86647b9b7e fix(rpc-base): deprecate widget_name in favor of object_name; closes #499 2025-04-22 21:19:37 +02:00
36dc174bfe test: add function scoped rpc_widgets e2e test; closes #510 2025-04-22 21:19:37 +02:00
a06f0600c1 fix(dark-mode-button): fix parent passed to QObjects in various classes 2025-04-22 21:19:37 +02:00
f88dfc8f1b refactor: add pragma no cover to various TYPE_CHECKING 2025-04-22 21:19:37 +02:00
c70cd9d6e8 fix(moduar-toolbar): fix cleanup of modular toolbar and dock_area 2025-04-22 21:19:37 +02:00
8fbd54c3aa fix(website-widget): add super().cleanup() in website widget 2025-04-22 21:19:37 +02:00
ef4a52cc17 fix: RPC access enabled for certain widgets. 2025-04-22 21:19:37 +02:00
b460ea9955 fix(progress-ring-bar): fix parent inheritance and cleanup of ring objects; closes #496 2025-04-22 21:19:37 +02:00
1fe052e9da docs: grammar improvement 2025-04-22 15:22:18 +02:00
f2d5b57e86 fix(docs): update copyright year to be dynamic 2025-04-22 15:22:18 +02:00
6630ba1c42 docs(auto_updates): update documentation for auto updates functionality and add launcher image 2025-04-22 15:22:18 +02:00
ef148317de fix: wrap fetching plugin widgets in case of errors 2025-04-15 20:13:11 +02:00
e10f5ec088 test(launch_window): tests for default and plugin auto updates 2025-04-15 12:26:09 +02:00
33a8a767f3 test(launch_window): add test for launching UI file that raises ValueError for QMainWindow 2025-04-15 12:08:06 +02:00
8efa93d2d2 feat(launch_window): add user access permissions 2025-04-15 12:07:54 +02:00
29653239c5 feat(launch_window): enhance auto update functionality with selector and dynamic loading 2025-04-15 11:44:26 +02:00
778230b5ed feat(auto_updates): enforce rpc widget class for subclasses of auto updates 2025-04-15 11:41:03 +02:00
b7795b4d0a refactor(client_utils): remove unused auto update attributes from BECGuiClient 2025-04-15 11:40:22 +02:00
c434af9b92 feat(plugin_utils): add functionality to retrieve auto update classes from plugins 2025-04-15 11:40:04 +02:00
be722683a7 fix(main_window): show app id only when connected to redis 2025-04-15 09:10:35 +02:00
9a940bb8d5 refactor(launch_window): remove cleanup method 2025-04-15 08:59:17 +02:00
a6ce312f7c refactor(ui_loader): remove unused import 2025-04-15 08:58:59 +02:00
d5e422c7fc test(launch_window): add unit tests for LaunchWindow initialization and custom UI file launching 2025-04-15 08:58:14 +02:00
3cd6e05b24 fix(launch_window): update LaunchTile icon to use new UI loader tile image 2025-04-14 21:56:27 +02:00
3089ca15ec feat(launch_window): add custom UI file launching functionality and UI tile 2025-04-14 21:42:22 +02:00
d60cf6c843 refactor(ui_loader): remove unnecessary parent_id handling 2025-04-14 21:41:54 +02:00
45cd82e635 feat(ui_launch_window): add UILaunchWindow class 2025-04-14 21:40:46 +02:00
f653fc5f7e feat(positioner_box): add units QLabel to device UI components and update visibility logic 2025-04-14 13:33:11 +02:00
d6fccd10f5 fix(rpc_server): update _serialize_bec_connector to include wait parameter for registration check 2025-04-14 10:26:31 +02:00
064343acf2 fix(bec_connector): add setObjectName method to update object name and broadcast if registered; closes #472 2025-04-14 10:26:31 +02:00
82b82659b7 fix(rpc_register): change add_rpc parameter type to BECConnector and add object_is_registered method 2025-04-14 10:26:31 +02:00
1921444e15 fix(bec_connector): add assertion to ensure BECConnector is used with a QObject; closes #475 2025-04-14 10:26:31 +02:00
3b16c9f5a2 fix(bec_connector): move RPC registration into single shot method to ensure the rpc name is in sync 2025-04-14 10:26:31 +02:00
4381fcc4c2 fix(designer): avoid touching deleted widgets during init as QtDesigner will segfault 2025-04-14 10:26:31 +02:00
e4e9febc98 fix(ring_progress_bar): replaced hard-coded endpoints by MessageEndpoints 2025-04-14 10:16:47 +02:00
ac9224e5f2 refactor(auto_updates): move cleanup method from user section to internal section 2025-04-14 10:04:43 +02:00
18e4ba6cfe fix(auto_updates): fix condition to skip auto update 2025-04-14 10:04:43 +02:00
cfc8272ac2 docs: add missing class doc strings for rpc-enabled widgets 2025-04-12 21:14:01 +02:00
d2c90757c2 docs: better document logpanel code 2025-04-11 18:27:28 +02:00
1d7b423bb3 fix: warning in logpanel
- chain a signal to the child BecLogsQueue rather than passing the
signal instance in
2025-04-11 18:27:28 +02:00
cb91ebc0c3 refactor(rpc_server): add type hint for _get_becwidget_ancestor method parameter; minor cleanup of imports 2025-04-11 13:39:26 +02:00
08168f28d3 refactor(rpc_server): add type hints and docstrings for heartbeat and registry update methods 2025-04-11 13:37:42 +02:00
125afc8907 fix(rpc_server): enhance serialization logic for BECConnector objects and fix return types 2025-04-11 13:34:05 +02:00
4dc59aa5e9 fix(rpc_base): ensure message wait event is set after processing RPC response 2025-04-11 13:28:28 +02:00
96b31a4509 fix(client_utils): simplify RPC client instantiation in BECGuiClient 2025-04-11 13:25:10 +02:00
20a86ad325 fix(server): turn_off_the_lights cleanup fixed for parent_id widgets 2025-04-11 10:54:45 +02:00
7e65d4f2d6 fix(launch_window): redesign 2025-04-11 10:54:45 +02:00
11feeff37c fix(main_window): connected to theme change 2025-04-11 10:45:28 +02:00
c1bbb16dad fix(round_frame): orientation can be vertical 2025-04-11 10:45:28 +02:00
a5f1f4781e build(bec_lib): raised required version to 3.28.1 2025-04-11 10:45:28 +02:00
56c2827140 refactor(auto_update): auto_update changed to be BECMainWindow; removed auto update logic from BECDockArea 2025-04-11 10:45:28 +02:00
b03d2eaeed fix(waveform): dap curve flickering 2025-04-11 10:45:28 +02:00
3a82c95f60 fix(waveform, rpc_reference): __getitem__ removed form waveform and rpc_reference 2025-04-11 10:45:28 +02:00
5f272a66a4 feat(auto_update): add GUI highlight management for auto updates status 2025-04-11 10:45:28 +02:00
55baa84eb6 feat(main_window): add launcher menu and functionality to show launcher 2025-04-11 10:45:28 +02:00
b51d637c5f test(plot_base): test for plot base re-enabled 2025-04-11 10:45:28 +02:00
c97db6aaae fix(client): regenerated client 2025-04-11 10:45:28 +02:00
e725de3c45 fix(dock_area): close BECMainWindow if dock area is central widget 2025-04-11 10:45:28 +02:00
6082e7a690 refactor(rpc_server): cli_server renamed to rpc_server 2025-04-11 10:45:28 +02:00
8914f1d506 test(setting_dialog): test that settings reject calls cleanup 2025-04-11 10:45:28 +02:00
d06605122e test: qapp must shutdown cli server before checking for leaked QTimer 2025-04-11 10:45:28 +02:00
a8adb064f5 test(generate_cli): fix reference output 2025-04-11 10:45:28 +02:00
31c3b64d7b test(device_signal_input): fix init of device input widget 2025-04-11 10:45:28 +02:00
23bdd95d8c test(bec_connector): BECConnector requires a QObject 2025-04-11 10:45:28 +02:00
d1712552ff fix(cli): add type ignore comment to generated files 2025-04-11 10:45:28 +02:00
20a1c5ddb3 feat(launcher): add option for launching with auto updates 2025-04-11 10:45:28 +02:00
2511056557 feat!: add support for auto updates 2025-04-11 10:45:27 +02:00
99383b7715 refactor(launcher,main_window): launcher window moved to inherit from BECMainWindow 2025-04-11 10:45:27 +02:00
337a332ed1 fix(plot_framework): all widgets, popups and side menus cleanups adjusted 2025-04-11 10:45:27 +02:00
a1bec75115 fix(widgets)!: BECConnector resolves hierarchy including objectName, parent, parent_id upon init; all widgets adjusted 2025-04-11 10:45:27 +02:00
a2128ad8d6 fix(RPCReference): setattr added 2025-04-10 16:11:59 +02:00
5f27a90989 feat(server,launcher)!: RPC server separated with the launcher window introduced 2025-04-10 16:11:59 +02:00
39164feb18 fix(waveform): signals for x device can be defined from gui 2025-04-09 23:52:31 +02:00
af28e2e433 fix: support auto_range_x/y for viewAll during measurement 2025-04-09 14:35:52 +02:00
515d7ad055 refactor: add fallback to 'index' plotting in case of missmatch in length 2025-04-09 14:35:52 +02:00
0e276d4c09 refactor: add support to plot against x_data 2025-04-09 14:35:52 +02:00
ed2d958de6 refactor: improve plotting behaviour from history 2025-04-09 14:35:52 +02:00
25820a1cde refactor: set downsampling to auto=True, method 'peak', activate clipToView for (Async)-Curves and fix ViewAll hook from pg.view_box menu 2025-04-09 14:35:52 +02:00
7f7891dfa5 fix: add support for 'add_slice', add downsampling for performance improvements. add tests 2025-04-09 14:35:52 +02:00
b5015e4e72 built: cleanup gitlab-ci, remove pyqt6 related lines 2025-04-08 14:45:47 +02:00
7653e0877c hack: comment out segfaulting test 2025-04-07 14:19:37 +02:00
52a9f29bdc docs: add docs on widget plugins 2025-04-07 14:19:37 +02:00
ca2bb4f9b4 feat: add loader/helper for widget plugins 2025-04-07 14:19:37 +02:00
b4925918f7 refactor: tidy client generation and add options 2025-04-03 16:12:57 +02:00
43e1aa9505 fix: add designer plugin for ScanMetadata 2025-04-03 16:12:57 +02:00
28ae0d2b57 fix: expose common classes from bec_widgets package 2025-04-03 16:12:57 +02:00
7726d83b68 fix: create widget enum programatically 2025-04-03 16:12:57 +02:00
258 changed files with 14735 additions and 5121 deletions

41
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Bug report
description: File a bug report.
title: "[BUG]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Bug report:
- type: textarea
id: description
attributes:
label: Provide a brief description of the bug.
- type: textarea
id: expected
attributes:
label: Describe what you expected to happen and what actually happened.
- type: textarea
id: reproduction
attributes:
label: Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.
- type: input
id: version
attributes:
label: bec_widgets version
description: which version of BEC widgets was running?
- type: input
id: bec-version
attributes:
label: bec core version
description: which version of BEC core was running?
- type: textarea
id: extra
attributes:
label: Any extra info / data? e.g. log output...
- type: input
id: issues
attributes:
label: Related issues
description: please tag any related issues

View File

@@ -1,3 +1,13 @@
---
name: Documentation update request
about: Suggest an update to the docs
title: '[DOCS]: '
type: documentation
label: documentation
assignees: ''
---
## Documentation Section
[Specify the section or page of the documentation that needs updating]

View File

@@ -1,3 +1,13 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEAT]: '
type: feature
label: feature
assignees: ''
---
## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting]
@@ -37,4 +47,3 @@
## Additional Information
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]

64
.github/actions/bw_install/action.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: "BEC Widgets Install"
description: "Install BEC Widgets and related os dependencies"
inputs:
BEC_WIDGETS_BRANCH: # id of input
required: false
default: "main"
description: "Branch of BEC Widgets to install"
BEC_CORE_BRANCH: # id of input
required: false
default: "main"
description: "Branch of BEC Core to install"
OPHYD_DEVICES_BRANCH: # id of input
required: false
default: "main"
description: "Branch of Ophyd Devices to install"
PYTHON_VERSION: # id of input
required: false
default: "3.11"
description: "Python version to use"
runs:
using: "composite"
steps:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.PYTHON_VERSION }}
- name: Checkout BEC Core
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
path: ./bec
- name: Checkout Ophyd Devices
uses: actions/checkout@v4
with:
repository: bec-project/ophyd_devices
ref: ${{ inputs.OPHYD_DEVICES_BRANCH }}
path: ./ophyd_devices
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
path: ./bec_widgets
- name: Install dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Install Python dependencies
shell: bash
run: |
pip install uv
uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e ./bec_widgets[dev,pyside6]

View File

@@ -1,19 +1,24 @@
## Description
[Provide a brief description of the changes introduced by this merge request.]
[Provide a brief description of the changes introduced by this pull request.]
## Related Issues
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `fixes #123` or `closes #123`.]
[Cite any related issues or feature requests that are addressed or resolved by this pull request. Link the associated issue, for example, with `fixes #123` or `closes #123`.]
## Type of Change
- Change 1
- Change 2
## How to test
- Run unit tests
- Open [widget] in designer and play around with the properties
## Potential side effects
[Describe any potential side effects or risks of merging this MR.]
[Describe any potential side effects or risks of merging this PR.]
## Screenshots / GIFs (if applicable)

View File

@@ -0,0 +1,342 @@
import functools
import os
from typing import Literal
import requests
from github import Github
from pydantic import BaseModel
class GHConfig(BaseModel):
token: str
organization: str
repository: str
project_number: int
graphql_url: str
rest_url: str
headers: dict
class ProjectItemHandler:
"""
A class to handle GitHub project items.
"""
def __init__(self, gh_config: GHConfig):
self.gh_config = gh_config
self.gh = Github(gh_config.token)
self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}")
self.project_node_id = self.get_project_node_id()
def set_issue_status(
self,
status: Literal[
"Selected for Development",
"Weekly Backlog",
"In Development",
"Ready For Review",
"On Hold",
"Done",
],
issue_number: int | None = None,
issue_node_id: str | None = None,
):
"""
Set the status field of a GitHub issue in the project.
Args:
status (str): The status to set. Must be one of the predefined statuses.
issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided.
issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided.
"""
if not issue_number and not issue_node_id:
raise ValueError("Either issue_number or issue_node_id must be provided.")
if issue_number and issue_node_id:
raise ValueError("Only one of issue_number or issue_node_id must be provided.")
if issue_number is not None:
issue = self.repo.get_issue(issue_number)
issue_id = self.get_issue_info(issue.node_id)[0]["id"]
else:
issue_id = issue_node_id
field_id, option_id = self.get_status_field_id(field_name=status)
self.set_field_option(issue_id, field_id, option_id)
def run_graphql(self, query: str, variables: dict) -> dict:
"""
Execute a GraphQL query against the GitHub API.
Args:
query (str): The GraphQL query to execute.
variables (dict): The variables to pass to the query.
Returns:
dict: The response from the GitHub API.
"""
response = requests.post(
self.gh_config.graphql_url,
json={"query": query, "variables": variables},
headers=self.gh_config.headers,
timeout=10,
)
if response.status_code != 200:
raise Exception(
f"Query failed with status code {response.status_code}: {response.text}"
)
return response.json()
def get_project_node_id(self):
"""
Retrieve the project node ID from the GitHub API.
"""
query = """
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
}
}
}
"""
variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number}
resp = self.run_graphql(query, variables)
return resp["data"]["organization"]["projectV2"]["id"]
def get_issue_info(self, issue_node_id: str):
"""
Get the project-related information for a given issue node ID.
Args:
issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I".
Returns:
list[dict]: A list of project items associated with the issue.
"""
query = """
query($issueId: ID!) {
node(id: $issueId) {
... on Issue {
projectItems(first: 10) {
nodes {
project {
id
title
}
id
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2SingleSelectField {
name
}
}
}
}
}
}
}
}
}
}
"""
variables = {"issueId": issue_node_id}
resp = self.run_graphql(query, variables)
return resp["data"]["node"]["projectItems"]["nodes"]
def get_status_field_id(
self,
field_name: Literal[
"Selected for Development",
"Weekly Backlog",
"In Development",
"Ready For Review",
"On Hold",
"Done",
],
) -> tuple[str, str]:
"""
Get the status field ID and option ID for the given field name in the project.
Args:
field_name (str): The name of the field to retrieve.
Must be one of the predefined statuses.
Returns:
tuple[str, str]: A tuple containing the field ID and option ID.
"""
field_id = None
option_id = None
project_fields = self.get_project_fields()
for field in project_fields:
if field["name"] != "Status":
continue
field_id = field["id"]
for option in field["options"]:
if option["name"] == field_name:
option_id = option["id"]
break
if not field_id or not option_id:
raise ValueError(f"Field '{field_name}' not found in project fields.")
return field_id, option_id
def set_field_option(self, item_id, field_id, option_id):
"""
Set the option of a project item for a single-select field.
Args:
item_id (str): The ID of the project item to update.
field_id (str): The ID of the field to update.
option_id (str): The ID of the option to set.
"""
mutation = """
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
"""
variables = {
"projectId": self.project_node_id,
"itemId": item_id,
"fieldId": field_id,
"optionId": option_id,
}
return self.run_graphql(mutation, variables)
@functools.lru_cache(maxsize=1)
def get_project_fields(self) -> list[dict]:
"""
Get the available fields in the project.
This method caches the result to avoid multiple API calls.
Returns:
list[dict]: A list of fields in the project.
"""
query = """
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
"""
variables = {"projectId": self.project_node_id}
resp = self.run_graphql(query, variables)
return list(filter(bool, resp["data"]["node"]["fields"]["nodes"]))
def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]:
"""
Get the linked issues of a pull request.
Args:
pr_number (int): The pull request number.
Returns:
list[dict]: A list of linked issues.
"""
query = """
query($number: Int!, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id
closingIssuesReferences(first: 50) {
edges {
node {
id
body
number
title
}
}
}
}
}
}
"""
variables = {
"number": pr_number,
"owner": self.gh_config.organization,
"repo": self.gh_config.repository,
}
resp = self.run_graphql(query, variables)
edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"]
return [edge["node"] for edge in edges if edge.get("node")]
def main():
# GitHub settings
token = os.getenv("TOKEN")
org = os.getenv("ORG")
repo = os.getenv("REPO")
project_number = os.getenv("PROJECT_NUMBER")
pr_number = os.getenv("PR_NUMBER")
if not token:
raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.")
if not org:
raise ValueError("GitHub organization is not set. Please set the ORG environment variable.")
if not repo:
raise ValueError("GitHub repository is not set. Please set the REPO environment variable.")
if not project_number:
raise ValueError(
"GitHub project number is not set. Please set the PROJECT_NUMBER environment variable."
)
if not pr_number:
raise ValueError(
"Pull request number is not set. Please set the PR_NUMBER environment variable."
)
project_number = int(project_number)
pr_number = int(pr_number)
gh_config = GHConfig(
token=token,
organization=org,
repository=repo,
project_number=project_number,
graphql_url="https://api.github.com/graphql",
rest_url=f"https://api.github.com/repos/{org}/{repo}/issues",
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
)
project_item_handler = ProjectItemHandler(gh_config=gh_config)
# Get PR info
pr = project_item_handler.repo.get_pull(pr_number)
# Get the linked issues of the pull request
linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number)
print(f"Linked issues: {linked_issues}")
target_status = "In Development" if pr.draft else "Ready For Review"
print(f"Target status: {target_status}")
for issue in linked_issues:
project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,2 @@
pydantic
pygithub

28
.github/workflows/check_pr.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Check PR status for branch
on:
workflow_call:
outputs:
branch-pr:
description: The PR number if the branch is in one
value: ${{ jobs.pr.outputs.branch-pr }}
jobs:
pr:
runs-on: "ubuntu-latest"
outputs:
branch-pr: ${{ steps.script.outputs.result }}
steps:
- uses: actions/github-script@v7
id: script
if: github.event_name == 'push' && github.event.ref_type != 'tag'
with:
script: |
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: context.repo.owner + ':${{ github.ref_name }}'
})
if (prs.data.length) {
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
return prs.data[0]["number"]
}

60
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Full CI
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
type: string
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
type: string
permissions:
pull-requests: write
jobs:
check_pr_status:
uses: ./.github/workflows/check_pr.yml
formatter:
needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test-matrix:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
generate-cli-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml

50
.github/workflows/end2end-conda.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Run Pytest with Coverage
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Conda install and run pytest
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

61
.github/workflows/formatter.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Formatter and Pylint jobs
on: [workflow_call]
jobs:
Formatter:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Run black and isort
run: |
pip install black isort
pip install -e .[dev]
black --check --diff --color .
isort --check --diff ./
Pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint pylint-exit anybadge
- name: Run Pylint
run: |
mkdir -p ./pylint
set +e
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
pylint-exit $?
set -e
- name: Extract Pylint Score
id: score
run: |
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: Create Badge
run: |
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: pylint-artifacts
path: |
# ./pylint/pylint.log # not sure why this isn't working
./pylint/pylint.svg

View File

@@ -0,0 +1,49 @@
name: Run bw-generate-cli
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install os dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run bw-generate-cli
run: |
bw-generate-cli --target bec_widgets
git diff --exit-code

59
.github/workflows/pytest-matrix.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Run Pytest with different Python versions
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
jobs:
pytest-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: ${{ matrix.python-version }}
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests

64
.github/workflows/pytest.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
required: false
default: 'main'
type: string
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
jobs:
pytest:
runs-on: ubuntu-latest
env:
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: 3.11
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets

103
.github/workflows/semantic_release.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Continuous Delivery
on:
push:
branches:
- main
# default: least privileged permissions across all jobs
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-release-${{ github.ref_name }}
cancel-in-progress: false
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
permissions:
contents: write
steps:
# Note: We checkout the repository at the branch that triggered the workflow
# with the entire history to ensure to match PSR's release branch detection
# and history evaluation.
# However, we forcefully reset the branch to the workflow sha because it is
# possible that the branch was updated while the workflow was running. This
# prevents accidentally releasing un-evaluated changes.
- name: Setup | Checkout Repository on Release Branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup | Force release branch to be at workflow sha
run: |
git reset --hard ${{ github.sha }}
- name: Evaluate | Verify upstream has NOT changed
# Last chance to abort before causing an error as another PR/push was applied to
# the upstream branch while this workflow was running. This is important
# because we are committing a version change (--commit). You may omit this step
# if you have 'commit: false' in your configuration.
#
# You may consider moving this to a repo script and call it from this step instead
# of writing it in-line.
shell: bash
run: |
set +o pipefail
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
set -o pipefail
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
exit 1
fi
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
exit 1
fi
HEAD_SHA="$(git rev-parse HEAD)"
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
exit 1
fi
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
- name: Semantic Version Release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pip install python-semantic-release==9.* wheel build twine
semantic-release -vv version
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
semantic-release publish

40
.github/workflows/sync-issues-pr.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Sync PR to Project
on:
pull_request:
types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize]
jobs:
sync-project:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
contents: read
env:
PROJECT_NUMBER: 3 # BEC Project
ORG: 'bec-project'
REPO: 'bec_widgets'
TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Set up python environment
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Checkout repo
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Install dependencies
run: |
pip install -r ./.github/scripts/pr_issue_sync/requirements.txt
- name: Sync PR to Project
run: |
python ./.github/scripts/pr_issue_sync/pr_issue_sync.py

3
.gitignore vendored
View File

@@ -64,6 +64,9 @@ coverage.xml
.pytest_cache/
cover/
# Output from end2end testing
tests/reference_failures/
# Translations
*.mo
*.pot

View File

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

View File

@@ -1,17 +0,0 @@
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]

View File

@@ -7,13 +7,13 @@ version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-20.04
os: ubuntu-22.04
tools:
python: "3.10"
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
configuration: docs/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF
# formats:
@@ -21,5 +21,7 @@ sphinx:
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
install:
- requirements: docs/requirements.txt
- method: pip
path: .[dev]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2023, bec
Copyright (c) 2025, Paul Scherrer Institute
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@@ -1,5 +1,16 @@
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
from bec_widgets.cli.auto_updates import AutoUpdates
from __future__ import annotations
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
def dock_area(object_name: str | None = None):
_dock_area = BECDockArea(object_name=object_name)
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
return _dock_area
def auto_update_dock_area(object_name: str | None = None) -> BECDockArea:
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
"""
Create a dock area with auto update enabled.
@@ -17,7 +19,5 @@ def auto_update_dock_area(object_name: str | None = None) -> BECDockArea:
Returns:
BECDockArea: The created dock area.
"""
_dock_area = BECDockArea(object_name=object_name)
_dock_area.set_auto_update(AutoUpdates)
_dock_area.auto_update.enabled = True # type:ignore
return _dock_area
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -1,35 +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>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QPushButton" name="open_dock_area">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="open_auto_update_dock_area">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,26 +1,187 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QSizePolicy
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QWidget,
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
class LaunchTile(RoundedFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal()
def __init__(
self,
parent: QObject | None = None,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
tile_size: tuple[int, int] | None = None,
):
super().__init__(parent=parent, orientation="vertical")
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
tile_size = self.DEFAULT_SIZE
self.tile_size = tile_size
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
size = 100
circular_pixmap = QPixmap(size, size)
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
# Desired default appearance
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setAlignment(Qt.AlignCenter)
# Shrink font if the default would wrap on this platform / DPI
content_width = (
self.tile_size[0]
- self.layout.contentsMargins().left()
- self.layout.contentsMargins().right()
)
self._fit_label_to_width(self.main_label, content_width)
# Give every tile the same reserved height for the title so the
# description labels start at an identical yoffset.
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
if show_selector:
self.selector = QComboBox(self)
self.layout.addWidget(self.selector)
else:
self.selector = None
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.layout.addItem(self.spacer_bottom)
# Action button
self.action_button = QPushButton("Open")
self.action_button.setStyleSheet(
"""
QPushButton {
background-color: #007AFF;
border: none;
padding: 8px 16px;
color: white;
border-radius: 6px;
font-weight: bold;
}
QPushButton:hover {
background-color: #005BB5;
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
Fit the label text to the specified maximum width by adjusting the font size.
Args:
label(QLabel): The label to adjust.
max_width(int): The maximum width the label can occupy.
min_pt(int): The minimum font point size to use.
"""
font = label.font()
for pt in range(font.pointSize(), min_pt - 1, -1):
font.setPointSize(pt)
metrics = QFontMetrics(font)
if metrics.horizontalAdvance(label.text()) <= max_width:
label.setFont(font)
label.setWordWrap(False)
return
# If nothing fits, fall back to eliding
metrics = QFontMetrics(font)
label.setFont(font)
label.setWordWrap(False)
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
class LaunchWindow(BECMainWindow):
RPC = True
TILE_SIZE = (250, 300)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
@@ -28,25 +189,150 @@ class LaunchWindow(BECMainWindow):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Track the smallest mainlabel font size chosen so far
self._min_main_label_pt: int | None = None
self.resize(500, 300)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
ui_file_path = os.path.join(MODULE_PATH, "applications/launch_dialog.ui")
self.load_ui(ui_file_path)
self.ui.open_dock_area.setText("Open Dock Area")
self.ui.open_dock_area.clicked.connect(lambda: self.launch("dock_area"))
self.ui.open_auto_update_dock_area.setText("Open Dock Area with Auto Update")
self.ui.open_auto_update_dock_area.clicked.connect(
lambda: self.launch("auto_update_dock_area", "auto_updates")
# Main Widget
self.central_widget = QWidget(self)
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.register_tile(
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
)
self.register_tile(
name="custom_ui_file",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
)
# plugin widgets
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
tile_size=self.TILE_SIZE,
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
tile.selector.addItems(selector_items)
self.central_widget.layout.addWidget(tile)
# keep all tiles' main labels at a unified point size
current_pt = tile.main_label.font().pointSize()
if self._min_main_label_pt is None or current_pt < self._min_main_label_pt:
# New global minimum shrink every existing tile to this size
self._min_main_label_pt = current_pt
for t in self.tiles.values():
f = t.main_label.font()
f.setPointSize(self._min_main_label_pt)
t.main_label.setFont(f)
t.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
elif current_pt > self._min_main_label_pt:
# Tile is larger than global minimum shrink it to match
f = tile.main_label.font()
f.setPointSize(self._min_main_label_pt)
tile.main_label.setFont(f)
tile.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
self.tiles[name] = tile
def launch(
self,
launch_script: str,
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
) -> QWidget:
**kwargs,
) -> QWidget | None:
"""Launch the specified script. If the launch script creates a QWidget, it will be
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
as a separate window.
@@ -67,6 +353,8 @@ class LaunchWindow(BECMainWindow):
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
@@ -75,6 +363,23 @@ class LaunchWindow(BECMainWindow):
launch_script = "dock_area"
if not isinstance(launch_script, str):
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
if launch_script == "custom_ui_file":
ui_file = kwargs.pop("ui_file", None)
if not ui_file:
return None
return self._launch_custom_ui_file(ui_file)
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
@@ -84,7 +389,7 @@ class LaunchWindow(BECMainWindow):
# TODO Should we simply use the specified name as title here?
result_widget.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
logger.info(f"Existing dock areas: {geometry}")
if geometry is not None:
result_widget.setGeometry(*geometry)
if isinstance(result_widget, BECMainWindow):
@@ -92,14 +397,187 @@ class LaunchWindow(BECMainWindow):
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
widget = root.find("widget")
if widget is None:
raise ValueError("No widget found in the UI file.")
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
else:
auto_update = "auto_updates"
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles.values():
tile.apply_theme(theme)
super().apply_theme(theme)
def _open_auto_update(self):
"""
Open the auto update window.
"""
if self.tiles["auto_update"].selector is None:
auto_update = None
else:
auto_update = self.tiles["auto_update"].selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_widget(self):
"""
Open a widget from the available widgets.
"""
if self.tiles["widget"].selector is None:
return
widget = self.tiles["widget"].selector.currentText()
if widget not in self.available_widgets:
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""
Open a file dialog to select a custom UI file and launch it.
"""
ui_file, _ = QFileDialog.getOpenFileName(
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
)
self.launch("custom_ui_file", ui_file=ui_file)
@staticmethod
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Load all available auto updates from the plugin repository.
"""
try:
auto_updates = get_plugin_auto_updates()
logger.info(f"Available auto updates: {auto_updates.keys()}")
except Exception as exc:
logger.error(f"Failed to load auto updates: {exc}")
return {}
return auto_updates
def show_launcher(self):
"""
Show the launcher window.
"""
self.show()
def hide_launcher(self):
"""
Hide the launcher window.
"""
self.hide()
def cleanup(self):
super().close()
def showEvent(self, event):
super().showEvent(event)
self.setFixedSize(self.size())
def _launcher_is_last_widget(self, connections: dict) -> bool:
"""
Check if the launcher is the last widget in the application.
"""
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
if self._launcher_is_last_widget(connections):
self.show()
self.activateWindow()
self.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True) # type: ignore
return
self.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False) # type: ignore
def closeEvent(self, event):
"""
Close the launcher window.
"""
connections = self.register.list_all_connections()
if self._launcher_is_last_widget(connections):
event.accept()
return
event.ignore()
self.hide()
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
@@ -31,7 +32,8 @@ logger = bec_logger.logger
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
str | bool | dict,
]
# pylint: disable=redefined-outer-scope
@@ -204,8 +206,6 @@ class BECGuiClient(RPCBase):
super().__init__(**kwargs)
self._lock = Lock()
self._anchor_widget = "launcher"
self._auto_updates_enabled = True
self._auto_updates = None
self._killed = False
self._top_level: dict[str, RPCReference] = {}
self._startup_timeout = 0
@@ -216,6 +216,7 @@ class BECGuiClient(RPCBase):
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
####################
#### Client API ####
@@ -277,6 +278,8 @@ class BECGuiClient(RPCBase):
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
**kwargs,
) -> client.BECDockArea:
"""Create a new top-level dock area.
@@ -291,14 +294,12 @@ class BECGuiClient(RPCBase):
self.start(wait=True)
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
widget = rpc_client._run_rpc(
"launch", "dock_area", name, geometry
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
@@ -387,7 +388,7 @@ class BECGuiClient(RPCBase):
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
gui_class_id="bec", # FIXME me experiment
gui_class_id="bec",
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)

View File

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

View File

@@ -10,17 +10,15 @@ from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
import bec_widgets.cli.client as client
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from bec_lib.connector import MessageObject
import bec_widgets.cli.client as client
from bec_widgets.cli.client_utils import BECGuiClient
else:
client = lazy_import("bec_widgets.cli.client") # avoid circular import
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
# pylint: disable=protected-access
@@ -95,21 +93,17 @@ class RPCReference:
@check_for_deleted_widget
def __getattr__(self, name):
if name in ["_registry", "_gui_id", "_is_deleted", "_name", "object_name"]:
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__getattribute__(name)
return self._registry[self._gui_id].__getattribute__(name)
def __setattr__(self, name, value):
if name in ["_registry", "_gui_id", "_is_deleted", "_name", "object_name"]:
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__setattr__(name, value)
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
self._registry[self._gui_id].__setattr__(name, value)
@check_for_deleted_widget
def __getitem__(self, key):
return self._registry[self._gui_id].__getitem__(key)
def __repr__(self):
if self._gui_id not in self._registry:
return f"<Deleted widget with gui_id {self._gui_id}>"
@@ -136,6 +130,7 @@ class RPCBase:
config: dict | None = None,
object_name: str | None = None,
parent=None,
**kwargs,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
@@ -150,21 +145,21 @@ class RPCBase:
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.widget_name}>"
return f"<{qualname} with name: {self.object_name}>"
def remove(self):
"""
Remove the widget.
"""
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
if proxy := obj.get("container_proxy"):
assert isinstance(proxy, str)
self._run_rpc("remove", gui_id=proxy)
return
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self.object_name
@property
def _root(self) -> BECGuiClient:
"""
@@ -177,7 +172,15 @@ class RPCBase:
parent = parent._parent
return parent # type: ignore
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=300, **kwargs) -> Any:
def _run_rpc(
self,
method,
*args,
wait_for_rpc_response=True,
timeout=5,
gui_id: str | None = None,
**kwargs,
) -> Any:
"""
Run the RPC call.
@@ -185,6 +188,8 @@ class RPCBase:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
@@ -193,7 +198,7 @@ class RPCBase:
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access
@@ -233,8 +238,8 @@ class RPCBase:
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._msg_wait_event.set()
parent._rpc_response = msg
parent._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:

View File

@@ -65,7 +65,7 @@ class RPCRegister:
return register._broadcast_on_hold
@broadcast_update
def add_rpc(self, rpc: QObject):
def add_rpc(self, rpc: BECConnector):
"""
Add an RPC object to the register.
@@ -136,6 +136,18 @@ class RPCRegister:
for callback in self.callbacks:
callback(connections)
def object_is_registered(self, obj: BECConnector) -> bool:
"""
Check if an object is registered in the RPC register.
Args:
obj(QObject): The object to check.
Returns:
bool: True if the object is registered, False otherwise.
"""
return obj.gui_id in self._rpc_register
def add_callback(self, callback: Callable[[dict], None]):
"""
Add a callback that will be called whenever the registry is updated.

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import get_custom_classes
class RPCWidgetHandler:
@@ -31,12 +31,9 @@ class RPCWidgetHandler:
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_custom_classes("bec_widgets")
self._widget_classes = {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""

View File

@@ -6,7 +6,6 @@ import os
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
@@ -38,6 +37,10 @@ class SimpleFileLikeFromLogOutputFunc:
self._log_func(lines)
self._buffer = [remaining]
@property
def encoding(self):
return "utf-8"
def close(self):
return
@@ -83,24 +86,6 @@ class GUIServer:
service_config = ServiceConfig()
return service_config
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
self.launcher_window = cast(LaunchWindow, self.launcher_window)
if len(connections) <= 1:
self.launcher_window.show()
self.launcher_window.activateWindow()
self.launcher_window.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True)
else:
self.launcher_window.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False)
def _run(self):
"""
Run the GUI server.
@@ -120,10 +105,6 @@ class GUIServer:
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
register = RPCRegister()
register.callbacks.append(self._turn_off_the_lights)
register.broadcast()
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.

View File

@@ -34,20 +34,17 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
super().__init__(parent)
self._init_ui()
self.app = QApplication.instance()
# console push
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"app": self.app,
"pg": pg,
"wh": wh,
"dock_area": self.dock_area,
"dock_1": self.dock_1,
"wf": self.wf,
# "dock_2": self.dock_2,
"dock": self.dock,
"im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
@@ -74,17 +71,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
tab_widget = QTabWidget(splitter)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock_area = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock_area)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
self._init_dock()
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
@@ -101,7 +92,10 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
#
# tab_widget.setCurrentIndex(3)
#
#
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
@@ -118,6 +112,14 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=True)
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
@@ -139,26 +141,21 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(0)
# tab_widget.setCurrentIndex(8)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
self.setWindowTitle("Jupyter Console Window")
#
# self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def _init_dock(self):
self.dock_1 = self.dock_area.new(name="dock_0")
self.wf = self.dock_1.new(widget="Waveform")
# self.dock_2 = self.dock_area.new(widget="DarkModeButton")
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock_area.cleanup()
self.dock_area.close()
self.dock.cleanup()
self.dock.close()
self.console.close()
super().closeEvent(event)
@@ -177,7 +174,7 @@ if __name__ == "__main__": # pragma: no cover
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
client = bec_dispatcher.client
client.start()

View File

@@ -96,9 +96,9 @@ class FakePositioner(BECPositioner):
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
}
}
self.signals = {
@@ -200,7 +200,13 @@ class DMMock:
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devives(self, devices: list):
def add_devices(self, devices: list):
"""
Add devices to the DeviceContainer.
Args:
devices (list): List of device instances to add.
"""
for device in devices:
self.devices[device.name] = device

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import os
import time
import traceback
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
@@ -10,13 +11,11 @@ from typing import TYPE_CHECKING, Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
@@ -85,25 +84,46 @@ class BECConnector:
config: ConnectionConfig | None = None,
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away
parent_id: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
**kwargs,
):
"""
BECConnector mixin class to handle BEC client and device manager.
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration with specific gui id.
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
**kwargs:
"""
# Extract object_name from kwargs to not pass it to Qt class
object_name = object_name or kwargs.pop("objectName", None)
# Ensure the parent is always the first argument for QObject
parent = kwargs.pop("parent", None)
# This initializes the QObject or any qt related class
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
super().__init__(parent=parent, **kwargs)
assert isinstance(
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# flag to check if the object was destroyed and its cleanup was called
self._destroyed = False
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
self._parent_dock = parent_dock # TODO also remove at some point
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
self.rpc_register = RPCRegister()
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@pyqtSlot()
@SafeSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
@@ -123,7 +143,6 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
self.parent_id = parent_id
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
@@ -132,7 +151,6 @@ class BECConnector:
else:
self.gui_id: str = self.config.gui_id # type: ignore
# TODO Hierarchy can be refreshed upon creation -> also registry should be notified if objectName changes -> issue #472
if object_name is not None:
self.setObjectName(object_name)
@@ -143,14 +161,8 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if parent_id is None:
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
if connector_parent is not None:
self.parent_id = connector_parent.gui_id
self._enforce_unique_sibling_name()
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -159,6 +171,61 @@ class BECConnector:
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
QTimer.singleShot(0, self._update_object_name)
@property
def parent_id(self) -> str | None:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
return connector_parent.gui_id if connector_parent else None
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
@SafeSlot()
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
"""
Enforce a unique object name among siblings and register the object for RPC.
This method is called through a single shot timer kicked off in the constructor.
"""
# 1) Enforce unique objectName among siblings with the same BECConnector parent
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
def _enforce_unique_sibling_name(self):
"""
Enforce that this BECConnector has a unique objectName among its siblings.
@@ -167,6 +234,7 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
@@ -187,7 +255,7 @@ class BECConnector:
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
base_name = self.objectName()
base_name = self.object_name
if base_name not in used_names:
# Name is already unique among siblings
return
@@ -202,7 +270,20 @@ class BECConnector:
break
counter += 1
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
# pylint: disable=invalid-name
def setObjectName(self, name: str) -> None:
"""
Set the object name of the widget.
Args:
name (str): The new object name.
"""
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
@@ -330,7 +411,7 @@ class BECConnector:
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
# @pyqtSlot(str)
# @SafeSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
@@ -362,7 +443,7 @@ class BECConnector:
self.client = client
self.get_bec_shortcuts()
@pyqtSlot(ConnectionConfig) # TODO can be also dict
@SafeSlot(ConnectionConfig) # TODO can be also dict
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
@@ -380,7 +461,7 @@ class BECConnector:
def remove(self):
"""Cleanup the BECConnector"""
# If the widget is attached to a dock, remove it from the dock.
# TODO this should be handled by dock and dock are not by BECConnector
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
if self._parent_dock is not None:
self._parent_dock.delete(self.object_name)
# If the widget is from Qt, trigger its close method.

View File

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

View File

@@ -4,8 +4,9 @@ import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
@@ -14,30 +15,52 @@ from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils.serialization import register_serializer_extension
logger = bec_logger.logger
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.cli_server import CLIServer
from bec_widgets.utils.rpc_server import RPCServer
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
cb_signal = pyqtSignal(dict, dict)
def __init__(self, cb):
def __init__(self, cb: Callable, cb_info: dict | None = None):
"""
Initialize the QtThreadSafeCallback.
Args:
cb (Callable): The callback function to be wrapped.
cb_info (dict, optional): Additional information about the callback. Defaults to None.
"""
super().__init__()
self.cb_info = cb_info
self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return id(self.cb)
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -78,14 +101,13 @@ class BECDispatcher:
_instance = None
_initialized = False
client: BECClient
cli_server: CLIServer | None = None
cli_server: RPCServer | None = None
# TODO add custom gui id for server
def __new__(
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
gui_id: str | None = None,
*args,
**kwargs,
):
@@ -98,7 +120,9 @@ class BECDispatcher:
if self._initialized:
return
self._slots = collections.defaultdict(set)
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if self.client is None:
@@ -121,6 +145,8 @@ class BECDispatcher:
except redis.exceptions.ConnectionError:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
register_serializer_extension()
logger.success("Initialized BECDispatcher")
self.start_cli_server(gui_id=gui_id)
@@ -137,7 +163,8 @@ class BECDispatcher:
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
cb_info: dict | None = None,
**kwargs,
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
@@ -145,34 +172,40 @@ class BECDispatcher:
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot, **kwargs)
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
qt_slot.topics.update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
):
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics(Union[str, list]): The topic(s) to disconnect from
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub 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:
for connected_slot in self._registered_slots.values():
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[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics:
del self._registered_slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
@@ -183,11 +216,16 @@ class BECDispatcher:
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
remove_slots = []
for connected_slot in self._registered_slots.values():
connected_slot.topics.difference_update(set(topics_str))
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
def disconnect_all(self, *args, **kwargs):
"""
@@ -208,7 +246,7 @@ class BECDispatcher:
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
"""
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.cli_server import CLIServer
from bec_widgets.utils.rpc_server import RPCServer
if gui_id is None:
gui_id = self.generate_unique_identifier()
@@ -216,7 +254,7 @@ class BECDispatcher:
if not self.client.started:
logger.error("Cannot start CLI server without a running client")
return
self.cli_server = CLIServer(gui_id, dispatcher=self, client=self.client)
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
logger.success(f"Started CLI server with gui_id: {gui_id}")
def stop_cli_server(self):

View File

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

View File

@@ -1,225 +0,0 @@
from __future__ import annotations
import os
import random
import string
from typing import TYPE_CHECKING, Any
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.cli_server import CLIServer
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
if TYPE_CHECKING: # pragma: no cover
from bec_lib.client import BECClient
class BECApplication:
"""
Custom QApplication class for BEC applications.
"""
gui_id: str
dispatcher: BECDispatcher
rpc_register: RPCRegister
client: BECClient
is_bec_app: bool
cli_server: CLIServer
_instance: BECApplication
_initialized: bool
def __init__(
self,
*args,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str | None = None,
**kwargs,
):
if self._initialized:
return
self.app = QApplication.instance()
if self.app is None:
self.app = QApplication([])
self._initialize_bec_app(client, config, gui_id)
self._initialized = True
def _initialize_bec_app(
self, client=None, config: str | ServiceConfig | None = None, gui_id: str | None = None
):
"""
Initialize the BECApplication instance with the given client and configuration.
Args:
app: The QApplication instance to initialize.
client: The BECClient instance to use for communication.
config: The ServiceConfig instance to use for configuration.
gui_id: The unique identifier for this application.
"""
self.app.gui_id = gui_id or BECApplication.generate_unique_identifier()
self.app.dispatcher = BECDispatcher(client=client, config=config)
self.app.rpc_register = RPCRegister()
self.app.client = self.app.dispatcher.client # type: ignore
self.app.is_bec_app = True
self.app.aboutToQuit.connect(self.shutdown)
self.setup_bec_icon()
def __instancecheck__(self, instance: Any) -> bool:
return isinstance(instance, (QApplication, BECApplication))
def __getattr__(self, name: str) -> Any:
if hasattr(self.app, name):
return getattr(self.app, name)
return super().__getattribute__(name)
def __new__(cls, *args, **kwargs) -> BECApplication:
if not hasattr(cls, "_instance"):
cls._instance = super().__new__(cls)
cls._initialized = False
return cls._instance
@classmethod
def from_qapplication(
cls, client=None, config: str | ServiceConfig | None = None, gui_id: str | None = None
) -> BECApplication:
"""
Create a BECApplication instance from an existing QApplication instance.
"""
print("from_qapplication")
app = QApplication.instance()
if isinstance(app, BECApplication):
return app
return cls(client=client, config=config, gui_id=gui_id)
def setup_bec_icon(self):
"""
Set the BEC icon for the application
"""
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.setWindowIcon(icon)
@staticmethod
def generate_unique_identifier(length: int = 4) -> str:
"""
Generate a unique identifier for the application.
Args:
length: The length of the identifier. Defaults to 4.
Returns:
str: The unique identifier.
"""
allowed_chars = string.ascii_lowercase + string.digits
return "".join(random.choices(allowed_chars, k=length))
# # TODO not sure if needed
# def register_all(self):
# widgets = self.allWidgets()
# all_connections = self.rpc_register.list_all_connections()
# for widget in widgets:
# if not isinstance(widget, BECWidget):
# continue
# gui_id = getattr(widget, "gui_id", None)
# if gui_id and widget not in all_connections:
# self.rpc_register.add_rpc(widget)
# print(
# f"[BECQApplication]: Registered widget {widget.__class__} with GUI ID: {gui_id}"
# )
# # TODO not sure if needed
# def list_all_bec_widgets(self):
# widgets = self.allWidgets()
# bec_widgets = []
# for widget in widgets:
# if isinstance(widget, BECWidget):
# bec_widgets.append(widget)
# return bec_widgets
# def list_hierarchy(self, only_bec_widgets: bool = True, show_parent: bool = True):
# """
# List the hierarchy of all BECWidgets in this application.
# Args:
# only_bec_widgets (bool): If True, prints only BECWidgets. Non-BECWidgets are skipped but their children are still traversed.
# show_parent (bool): If True, displays the immediate BECWidget ancestor for each item.
# """
# bec_widgets = self.list_all_bec_widgets()
# # Identify top-level BECWidgets (whose parent is not another BECWidget)
# top_level = [
# w for w in bec_widgets if not isinstance(self._get_becwidget_ancestor(w), BECWidget)
# ]
# print("[BECQApplication]: Listing BECWidget hierarchy:")
# for widget in top_level:
# self._print_becwidget_hierarchy(
# widget, indent=0, only_bec_widgets=only_bec_widgets, show_parent=show_parent
# )
# def _print_becwidget_hierarchy(self, widget, indent=0, only_bec_widgets=True, show_parent=True):
# # Decide if this widget should be printed
# is_bec = isinstance(widget, BECWidget)
# print_this = (not only_bec_widgets) or is_bec
# parent_info = ""
# if show_parent and is_bec:
# ancestor = self._get_becwidget_ancestor(widget)
# if ancestor is not None:
# parent_info = f" parent={ancestor.__class__.__name__}"
# else:
# parent_info = " parent=None"
# if print_this:
# prefix = " " * indent
# print(
# f"{prefix}- {widget.__class__.__name__} (objectName={widget.objectName()}){parent_info}"
# )
# # Always recurse so deeper BECWidgets aren't missed
# for child in widget.children():
# # Skip known non-BECWidgets if only_bec_widgets is True, but keep recursion
# # We'll still call _print_becwidget_hierarchy to discover any BECWidget descendants.
# self._print_becwidget_hierarchy(
# child, indent + 2, only_bec_widgets=only_bec_widgets, show_parent=show_parent
# )
# def _get_becwidget_ancestor(self, widget):
# """
# Climb the .parent() chain until finding another BECWidget, or None.
# """
# p = widget.parent()
# while p is not None:
# if isinstance(p, BECWidget):
# return p
# p = p.parent()
# return None
def shutdown(self):
self.dispatcher.disconnect_all()
self.cli_server.shutdown()
self.rpc_register.reset_singleton()
delattr(self.app, "gui_id")
delattr(self.app, "dispatcher")
delattr(self.app, "rpc_register")
delattr(self.app, "client")
delattr(self.app, "is_bec_app")
delattr(self.app, "cli_server")
self._initialized = False
self._instance = None

View File

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

View File

@@ -4,8 +4,7 @@ from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from PySide6.QtCore import QObject
from qtpy.QtCore import Slot
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -33,8 +32,7 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
parent_dock: BECDock | None = None, # TODO should not be there
parent_id: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
**kwargs,
):
"""
@@ -56,12 +54,7 @@ class BECWidget(BECConnector):
"""
super().__init__(
client=client,
config=config,
gui_id=gui_id,
parent_dock=parent_dock,
parent_id=parent_id,
**kwargs,
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
@@ -78,13 +71,6 @@ class BECWidget(BECConnector):
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
def _ensure_bec_app(self):
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.bec_qapp import BECApplication
app = BECApplication.from_qapplication()
return app
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@@ -120,6 +106,8 @@ class BECWidget(BECConnector):
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
self.cleanup()
if not self._destroyed:
self.cleanup()
self._destroyed = True
finally:
super().closeEvent(event) # pylint: disable=no-member

View File

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

View File

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

View File

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

View File

@@ -34,13 +34,21 @@ class Crosshair(QObject):
coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple)
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
def __init__(
self,
plot_item: pg.PlotItem,
precision: int | None = None,
*,
min_precision: int = 2,
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.
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
parent (QObject, optional): Parent object for the QObject. Defaults to None.
"""
super().__init__(parent)
@@ -48,7 +56,9 @@ class Crosshair(QObject):
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self.precision = precision
self._precision = precision
self._min_precision = max(0, int(min_precision)) # ensure nonnegative
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)
@@ -85,13 +95,64 @@ class Crosshair(QObject):
self.items = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_2d = None
self.marker_2d_row = None
self.marker_2d_col = None
self.update_markers()
self.check_log()
self.check_derivatives()
self._connect_to_theme_change()
@property
def precision(self) -> int | None:
"""Fixed number of decimals; ``None`` enables dynamic mode."""
return self._precision
@precision.setter
def precision(self, value: int | None):
"""
Set the fixed number of decimals to display.
Args:
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
"""
self._precision = value
@property
def min_precision(self) -> int:
"""Lower bound on decimals when dynamic precision is used."""
return self._min_precision
@min_precision.setter
def min_precision(self, value: int):
"""
Set the lower bound on decimals when dynamic precision is used.
Args:
value(int): The minimum number of decimals to display. Must be non-negative.
"""
self._min_precision = max(0, int(value))
def _current_precision(self) -> int:
"""
Get the current precision based on the view range or fixed precision.
"""
if self._precision is not None:
return self._precision
# Dynamically choose precision from the smaller visible span
view_range = self.plot_item.vb.viewRange()
x_span = abs(view_range[0][1] - view_range[0][0])
y_span = abs(view_range[1][1] - view_range[1][0])
# Ignore zero spans that can appear during initialisation
spans = [s for s in (x_span, y_span) if s > 0]
span = min(spans) if spans else 1.0
exponent = np.floor(np.log10(span)) # order of magnitude
decimals = max(0, int(-exponent) + 1)
return max(self._min_precision, decimals)
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@@ -195,13 +256,23 @@ class Crosshair(QObject):
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
if self.marker_2d_row is not None and self.marker_2d_col is not None:
continue
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
# Create horizontal ROI for row highlighting
if item.image is None:
continue
self.marker_2d_row = pg.ROI(
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d)
self.marker_2d_row.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
def snap_to_data(
self, x: float, y: float
@@ -243,6 +314,8 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item))
image_2d = item.image
if image_2d is None:
continue
# 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))
@@ -311,6 +384,7 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
@@ -321,8 +395,8 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
@@ -330,7 +404,10 @@ class Crosshair(QObject):
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])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
@@ -364,6 +441,7 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
@@ -375,8 +453,8 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
@@ -384,7 +462,10 @@ class Crosshair(QObject):
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])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
@@ -424,14 +505,17 @@ class Crosshair(QObject):
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
precision = self._current_precision()
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
if image is None:
continue
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{self.precision}g}"
text += f"\nIntensity: {intensity:.{precision}f}"
break
# Update coordinate label
self.coord_label.setText(text)
@@ -450,9 +534,12 @@ class Crosshair(QObject):
self.clear_markers()
def cleanup(self):
if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d)
self.marker_2d = None
if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)

View File

@@ -17,13 +17,23 @@ class EntryValidator:
raise ValueError(f"Device '{name}' not found in current BEC session")
device = self.devices[name]
description = device.describe()
# Build list of available signal entries from device._info['signals']
signals_dict = getattr(device, "_info", {}).get("signals", {})
available_entries = [
sig.get("obj_name") for sig in signals_dict.values() if sig.get("obj_name")
]
# If no signals are found, means device is a signal, use the device name as the entry
if not available_entries:
available_entries = [name]
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
if entry not in available_entries:
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
f"Entry '{entry}' not found in device '{name}' signals. "
f"Available signals: '{available_entries}'"
)
return entry

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -9,6 +12,9 @@ from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
@@ -45,20 +51,70 @@ def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
specified in the respective pyproject.toml file using the following key:
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
e.g.
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {}
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated auto update {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_auto_updates(obj):
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
return (
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
)
@dataclass
class BECClassInfo:
name: str
module: str
file: str
obj: type
obj: type[BECWidget]
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
class BECClassContainer:
def __init__(self):
self._collection = []
def __init__(self, initial: Iterable[BECClassInfo] = []):
self._collection: list[BECClassInfo] = list(initial)
def __repr__(self):
return str(list(cl.name for cl in self.collection))
def __iter__(self):
return self._collection.__iter__()
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo):
"""
@@ -69,53 +125,44 @@ class BECClassContainer:
"""
self.collection.append(class_info)
@property
def names(self):
"""Return a list of class names"""
return [c.name for c in self]
@property
def collection(self):
"""
Get the collection of classes.
"""
"""Get the collection of classes."""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
"""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.
"""
"""Get all top-level classes."""
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
"""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_plugin]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
"""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.
"""
"""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_plugin and info.is_connector]
@property
def classes(self):
"""
Get all classes.
"""
"""Get all classes."""
return [info.obj for info in self.collection]
@@ -151,7 +198,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
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)
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):

View File

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

View File

@@ -4,7 +4,7 @@ import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable, TypeVar
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
@@ -18,16 +18,19 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from qtpy.QtCore import QObject
else:
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
T = TypeVar("T")
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
@@ -51,7 +54,7 @@ def rpc_exception_hook(err_func):
popup.custom_exception_hook = old_exception_hook
class CLIServer:
class RPCServer:
client: BECClient
@@ -81,6 +84,7 @@ class CLIServer:
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
@@ -98,7 +102,7 @@ class CLIServer:
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
except Exception:
content = traceback.format_exc()
logger.error(f"Error while executing RPC instruction: {content}")
self.send_response(request_id, False, {"error": content})
@@ -143,15 +147,28 @@ class CLIServer:
res = self.serialize_object(res)
return res
def serialize_object(self, obj):
if isinstance(obj, BECConnector):
# Respect RPC = False
if hasattr(obj, "RPC") and obj.RPC is False:
return None
return self._serialize_bec_connector(obj)
return obj
def serialize_object(self, obj: T) -> None | dict | T:
"""
Serialize all BECConnector objects.
def emit_heartbeat(self):
Args:
obj: The object to be serialized.
Returns:
None | dict | T: The serialized object or None if the object is not a BECConnector.
"""
if not isinstance(obj, BECConnector):
return obj
# Respect RPC = False
if getattr(obj, "RPC", True) is False:
return None
return self._serialize_bec_connector(obj, wait=True)
def emit_heartbeat(self) -> None:
"""
Emit a heartbeat message to the GUI server.
This method is called periodically to indicate that the server is still running.
"""
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
@@ -162,7 +179,11 @@ class CLIServer:
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict):
def broadcast_registry_update(self, connections: dict) -> None:
"""
Broadcast the registry update to all the callbacks.
This method is called whenever the registry is updated.
"""
data = {}
for key, val in connections.items():
if not isinstance(val, BECConnector):
@@ -170,6 +191,9 @@ class CLIServer:
if not getattr(val, "RPC", True):
continue
data[key] = self._serialize_bec_connector(val)
if self._broadcasted_data == data:
return
self._broadcasted_data = data
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
@@ -178,30 +202,53 @@ class CLIServer:
max_size=1,
)
def _serialize_bec_connector(self, connector: BECConnector) -> dict:
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
"""
Create the serialization dict for a single BECConnector,
setting 'parent_id' via the real nearest BECConnector parent.
Create the serialization dict for a single BECConnector.
Args:
connector (BECConnector): The BECConnector to serialize.
wait (bool): If True, wait until the object is registered in the RPC register.
Returns:
dict: The serialized BECConnector object.
"""
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
try:
parent = connector.parent()
if isinstance(parent, BECMainWindow):
container_proxy = parent.gui_id
else:
container_proxy = None
except Exception:
container_proxy = None
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
widget_class = connector.__class__.__name__
return {
"gui_id": connector.gui_id,
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
}
@staticmethod
def _get_becwidget_ancestor(widget):
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
from bec_widgets.utils import BECConnector
parent = widget.parent()
while parent is not None:
@@ -211,7 +258,15 @@ class CLIServer:
return None
# Suppose clients register callbacks to receive updates
def add_registry_update_callback(self, cb):
def add_registry_update_callback(self, cb: Callable) -> None:
"""
Add a callback to be called whenever the registry is updated.
The specified callback is called whenever the registry is updated.
Args:
cb (Callable): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self._registry_update_callbacks.append(cb)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector

View File

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

View File

@@ -31,6 +31,7 @@ class SidePanel(QWidget):
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
show_toolbar: bool = True,
):
super().__init__(parent=parent)
@@ -40,6 +41,7 @@ class SidePanel(QWidget):
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._show_toolbar = show_toolbar
self._panel_width = 0
self._panel_height = 0
@@ -59,7 +61,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -71,13 +73,14 @@ class SidePanel(QWidget):
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
if self._orientation in ("left", "right"):
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
if self._orientation == "left":
self.main_layout.addWidget(self.container)
else:
self.main_layout.insertWidget(0, self.container)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
@@ -89,7 +92,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -102,11 +105,13 @@ class SidePanel(QWidget):
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
@@ -233,21 +238,24 @@ class SidePanel(QWidget):
def add_menu(
self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget,
action_id: str | None = None,
icon_name: str | None = None,
tooltip: str | None = None,
title: str | None = None,
):
) -> int:
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
action_id(str | None): The ID of the action. Optional if no toolbar action is needed.
icon_name(str | None): The name of the icon. Optional if no toolbar action is needed.
tooltip(str | None): The tooltip for the action. Optional if no toolbar action is needed.
title(str | None): The title of the panel.
Returns:
int: The index of the added panel, which can be used with show_panel() and switch_to().
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
@@ -278,32 +286,35 @@ class SidePanel(QWidget):
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
if action_id is not None and icon_name is not None and tooltip is not None:
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
else:
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
action.action.toggled.connect(on_action_toggled)
return index
############################################
@@ -332,41 +343,56 @@ class ExampleApp(QMainWindow): # pragma: no cover
self.add_side_menus()
def add_side_menus(self):
# Example 1: With action, icon, and tooltip
widget1 = QWidget()
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
widget=widget1,
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
# Example 2: With action, icon, and tooltip
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
widget=widget2,
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
# Example 3: With action, icon, and tooltip
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
widget=widget3,
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
widget4 = QWidget()
layout4 = QVBoxLayout(widget4)
layout4.addWidget(QLabel("This panel has no toolbar button"))
layout4.addWidget(QLabel("It can only be shown programmatically"))
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
# Example of how to show the hidden panel programmatically after 3 seconds
from qtpy.QtCore import QTimer
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
@@ -31,6 +32,8 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
@@ -118,7 +121,7 @@ class IconAction(ToolBarAction):
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = QIcon()
icon.addFile(self.icon_path, size=QSize(20, 20))
self.action = QAction(icon, self.tooltip, target)
self.action = QAction(icon=icon, text=self.tooltip, parent=target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
@@ -128,7 +131,7 @@ class QtIconAction(ToolBarAction):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(self.icon, self.tooltip, parent)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
@@ -173,7 +176,11 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled,
color=self.color,
)
self.action = QAction(self.icon, self.tooltip, parent=parent)
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
@@ -212,12 +219,12 @@ class DeviceSelectionAction(ToolBarAction):
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
widget = QWidget(parent=target)
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(f"{self.label}")
label = QLabel(text=f"{self.label}", parent=target)
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
@@ -280,7 +287,9 @@ class SwitchableToolBarAction(ToolBarAction):
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action = QAction(
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
@@ -369,13 +378,13 @@ class WidgetAction(ToolBarAction):
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget()
self.container = QWidget(parent=target)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(f"{self.label}")
label_widget = QLabel(text=f"{self.label}", parent=target)
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
@@ -437,7 +446,7 @@ class ExpandableMenuAction(ToolBarAction):
)
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
sub_action = QAction(text=action.tooltip, parent=target)
sub_action.setIconVisibleInMenu(True)
if action.icon_path:
icon = QIcon()
@@ -521,7 +530,7 @@ class ModularToolBar(QToolBar):
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent)
super().__init__(parent=parent)
self.widgets = defaultdict(dict)
self.background_color = background_color
@@ -700,6 +709,85 @@ class ModularToolBar(QToolBar):
self.bundles[bundle_id].append(action_id)
self.update_separators()
def remove_action(self, action_id: str):
"""
Completely remove a single action from the toolbar.
The method takes care of both standalone actions and actions that are
part of an existing bundle.
Args:
action_id (str): Unique identifier for the action.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
# Identify potential bundle membership
parent_bundle = None
for b_id, a_ids in self.bundles.items():
if action_id in a_ids:
parent_bundle = b_id
break
# 1. Remove the QAction from the QToolBar and delete it
tool_action = self.widgets.pop(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
self.removeAction(tool_action.action)
tool_action.action.deleteLater()
# 2. Clean bundle bookkeeping if the action belonged to one
if parent_bundle:
self.bundles[parent_bundle].remove(action_id)
# If the bundle becomes empty, get rid of the bundle entry as well
if not self.bundles[parent_bundle]:
self.remove_bundle(parent_bundle)
# 3. Remove from the ordering list
self.toolbar_items = [
item
for item in self.toolbar_items
if not (item[0] == "action" and item[1] == action_id)
]
self.update_separators()
def remove_bundle(self, bundle_id: str):
"""
Remove an entire bundle (and all of its actions) from the toolbar.
Args:
bundle_id (str): Unique identifier for the bundle.
"""
if bundle_id not in self.bundles:
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
# Remove every action belonging to this bundle
for action_id in list(self.bundles[bundle_id]): # copy the list
if action_id in self.widgets:
tool_action = self.widgets.pop(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
self.removeAction(tool_action.action)
tool_action.action.deleteLater()
# Drop the bundle entry
self.bundles.pop(bundle_id, None)
# Remove bundle entry and its preceding separator (if any) from the ordering list
cleaned_items = []
skip_next_separator = False
for item_type, ident in self.toolbar_items:
if item_type == "bundle" and ident == bundle_id:
# mark to skip one following separator if present
skip_next_separator = True
continue
if skip_next_separator and item_type == "separator":
skip_next_separator = False
continue
cleaned_items.append((item_type, ident))
self.toolbar_items = cleaned_items
self.update_separators()
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show toolbar actions with checkboxes and icons.

View File

@@ -1,9 +1,8 @@
import inspect
from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy import PYQT6, PYSIDE6
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
@@ -13,7 +12,7 @@ if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict = None):
def __init__(self, baseinstance, custom_widgets: dict | None = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
@@ -21,16 +20,7 @@ if PYSIDE6:
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
# check if the custom widget has a parent_id argument
if "parent_id" in inspect.signature(self.custom_widgets[class_name]).parameters:
gui_id = getattr(self.baseinstance, "gui_id", None)
widget = self.custom_widgets[class_name](self.baseinstance, parent_id=gui_id)
else:
logger.warning(
f"Custom widget {class_name} does not have a parent_id argument. "
)
widget = self.custom_widgets[class_name](self.baseinstance)
widget = self.custom_widgets[class_name](self.baseinstance)
return widget
return super().createWidget(class_name, self.baseinstance, name)
@@ -41,9 +31,9 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}
self.custom_widgets = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict()
if PYSIDE6:
self.loader = self.load_ui_pyside6

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import shiboken6 as shb
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -422,6 +423,8 @@ class WidgetHierarchy:
"""
from bec_widgets.utils import BECConnector
if not shb.isValid(widget):
return None
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):

View File

@@ -7,11 +7,12 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
@@ -22,24 +23,36 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
class AutoUpdates:
class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"
def __init__(
self, parent=None, gui_id: str = None, window_title="Auto Update", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None
def __init__(self, dock_area: BECDockArea):
self.dock_area = dock_area
self.bec_dispatcher = dock_area.bec_dispatcher
self._default_dock = None # type:ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = False
self._enabled = True
self.start_auto_update()
def connect(self):
def start_auto_update(self):
"""
Establish all connections for the auto updates.
"""
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
def disconnect(self):
def stop_auto_update(self):
"""
Disconnect all connections for the auto updates.
"""
@@ -47,6 +60,26 @@ class AutoUpdates:
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
)
@property
def selected_device(self) -> str | None:
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
return self._auto_update_selected_device
@selected_device.setter
def selected_device(self, value: str | None) -> None:
"""
Set the selected device in the auto update config.
Args:
value(str): The selected device.
"""
self._auto_update_selected_device = value
@SafeSlot()
def _on_scan_status(self, content: dict, metadata: dict) -> None:
"""
@@ -125,7 +158,7 @@ class AutoUpdates:
"""
if selected_device is None:
selected_device = self.dock_area.selected_device
selected_device = self.selected_device
if selected_device:
return selected_device
if len(monitored_devices) > 0:
@@ -166,14 +199,25 @@ class AutoUpdates:
self._enabled = value
if value:
self.connect()
self.start_auto_update()
self.enable_gui_highlights(True)
self.on_start()
else:
self.disconnect()
self.stop_auto_update()
self.enable_gui_highlights(False)
self.on_stop()
def cleanup(self) -> None:
"""
Cleanup procedure to run when the auto updates are disabled.
"""
self.enabled = False
self.stop_auto_update()
self.dock_area.close()
self.dock_area.deleteLater()
self.dock_area = None
super().cleanup()
########################################################################
################# Update Functions #####################################
########################################################################
@@ -228,6 +272,9 @@ class AutoUpdates:
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if None in (dev_x, dev_y, dev_z):
return
# Clear the scatter waveform widget and plot the data
scatter.clear_all()
scatter.plot(

View File

@@ -12,10 +12,11 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
@@ -130,7 +131,6 @@ class BECDock(BECWidget, Dock):
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
parent_id: str | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
@@ -271,13 +271,15 @@ class BECDock(BECWidget, Dock):
"""
return list(widget_handler.widget_classes.keys())
def _get_list_of_widget_name_of_parent_dock_area(self):
docks = self.parent_dock_area.panel_list
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
if (docks := self.parent_dock_area.panel_list) is None:
return []
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
@@ -300,6 +302,9 @@ class BECDock(BECWidget, Dock):
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
row = self.layout.rowCount()
@@ -315,11 +320,7 @@ class BECDock(BECWidget, Dock):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget,
object_name=name,
parent_dock=self,
parent_id=self.gui_id,
parent=self,
widget_type=widget, object_name=name, parent_dock=self, parent=self
),
)
else:
@@ -415,6 +416,7 @@ class BECDock(BECWidget, Dock):
self.delete_all()
self.widgets.clear()
super().cleanup()
self.deleteLater()
def close(self):
"""

View File

@@ -1,9 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
@@ -15,6 +14,7 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
@@ -23,6 +23,7 @@ from bec_widgets.utils.toolbar import (
)
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
@@ -37,9 +38,6 @@ from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatus
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.cli.auto_updates import AutoUpdates
logger = bec_logger.logger
@@ -51,6 +49,10 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
"""
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
"""
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
@@ -67,8 +69,6 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock",
"attach_all",
"save_state",
"selected_device",
"selected_device.setter",
"restore_state",
]
@@ -100,12 +100,10 @@ class BECDockArea(BECWidget, QWidget):
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self.auto_update: AutoUpdates | None = None
self._auto_update_selected_device: str | None = None
self._instructions_visible = True
self.dark_mode_button = DarkModeButton(parent=self, parent_id=self.gui_id, toolbar=True)
self.dock_area = DockArea()
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(
parent=self,
actions={
@@ -165,8 +163,11 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar",
filled=True,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
),
},
),
@@ -184,7 +185,7 @@ class BECDockArea(BECWidget, QWidget):
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget()
self.spacer = QWidget(parent=self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
@@ -232,9 +233,11 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
# FIXME temporarily disabled -> issue #644
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
@@ -245,7 +248,8 @@ class BECDockArea(BECWidget, QWidget):
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
name = pascal_to_snake(widget_name)
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
@@ -258,35 +262,6 @@ class BECDockArea(BECWidget, QWidget):
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def selected_device(self) -> str | None:
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
return self._auto_update_selected_device
@selected_device.setter
def selected_device(self, value: str | None) -> None:
"""
Set the selected device in the auto update config.
Args:
value(str): The selected device.
"""
self._auto_update_selected_device = value
def set_auto_update(self, auto_update_cls: type[AutoUpdates]) -> None:
"""
Set the auto update object for the dock area.
Args:
auto_update(AutoUpdates): The auto update object.
"""
self.auto_update = auto_update_cls(self)
@property
def panels(self) -> dict[str, BECDock]:
"""
@@ -338,6 +313,8 @@ class BECDockArea(BECWidget, QWidget):
"""
if state is None:
state = self.config.docks_state
if state is None:
return
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
@@ -394,6 +371,8 @@ class BECDockArea(BECWidget, QWidget):
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self.object_name} and id {self.gui_id}."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
@@ -402,7 +381,6 @@ class BECDockArea(BECWidget, QWidget):
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
parent_id=self.gui_id,
closable=closable,
)
dock.config.position = position
@@ -471,15 +449,9 @@ class BECDockArea(BECWidget, QWidget):
"""
Cleanup the dock area.
"""
if self.auto_update:
self.auto_update.enabled = False
self.delete_all()
self.toolbar.close()
self.toolbar.deleteLater()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
super().cleanup()
def show(self):
@@ -528,7 +500,18 @@ class BECDockArea(BECWidget, QWidget):
# self._broadcast_update()
def remove(self) -> None:
"""Remove the dock area."""
"""
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
parent = self.parent()
if isinstance(parent, BECMainWindow):
central_widget = parent.centralWidget()
if central_widget is self:
# Closing the parent will also close the dock area
parent.close()
return
self.close()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import math
import sys
from typing import Dict, Literal, Optional, Set, Tuple, Union
@@ -51,7 +53,7 @@ class LayoutManagerWidget(QWidget):
self,
widget: QWidget | str,
row: int | None = None,
col: Optional[int] = None,
col: int | None = None,
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
@@ -136,6 +138,39 @@ class LayoutManagerWidget(QWidget):
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
# Determine new widget position based on the specified relative position
# If adding to the left or right with shifting, shift the entire column
if (
position in ("left", "right")
and shift_existing
and shift_direction in ("left", "right")
):
column = ref_col
# Collect all rows in this column and sort for safe shifting
rows = sorted(
{row for (row, col) in self.position_widgets.keys() if col == column},
reverse=(shift_direction == "right"),
)
# Shift each widget in the column
for r in rows:
self.shift_widgets(direction=shift_direction, start_row=r, start_col=column)
# Update reference widget's position after the column shift
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
new_row = ref_row
# Compute insertion column based on relative position
if position == "left":
new_col = ref_col - ref_colspan
else:
new_col = ref_col + ref_colspan
# Add the new widget without triggering another shift
return self.add_widget(
widget=widget,
row=new_row,
col=new_col,
rowspan=rowspan,
colspan=colspan,
shift_existing=False,
)
if position == "left":
new_row = ref_row
new_col = ref_col - 1

View File

@@ -1,42 +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>824</width>
<height>1234</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="Waveform" name="waveform"/>
</item>
<item>
<widget class="BECDockArea" name="dock_area_2"/>
</item>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>Waveform</class>
<extends>QWidget</extends>
<header>waveform</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,262 +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>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

@@ -18,6 +18,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
PLUGIN = False
def __init__(
self,
@@ -33,6 +34,7 @@ class BECMainWindow(BECWidget, QMainWindow):
self.app = QApplication.instance()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
def _init_ui(self):
@@ -64,8 +66,16 @@ class BECMainWindow(BECWidget, QMainWindow):
self.setCentralWidget(self.ui)
def display_app_id(self):
server_id = self.bec_dispatcher.cli_server.gui_id
self.statusBar().showMessage(f"App ID: {server_id}")
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
@@ -162,50 +172,18 @@ class BECMainWindow(BECWidget, QMainWindow):
central_widget = self.centralWidget()
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
children = self.findChildren(BECWidget)
for child in children:
ancestor = WidgetHierarchy._get_becwidget_ancestor(child)
if ancestor is self:
child.cleanup()
child.close()
child.deleteLater()
super().cleanup()
class WindowWithUi(BECMainWindow):
"""
This is just testing app wiht UI file which could be connected to RPC.
"""
USER_ACCESS = ["new_dock_area", "all_connections", "change_theme", "hierarchy"]
def __init__(self, *args, name: str = None, **kwargs):
super().__init__(gui_id="test", *args, **kwargs)
if name is None:
name = self.__class__.__name__
else:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
ui_file_path = os.path.join(os.path.dirname(__file__), "example_app.ui")
self.load_ui(ui_file_path)
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
@property
def all_connections(self) -> list:
all_connections = self.rpc_register.list_all_connections()
all_connections_keys = list(all_connections.keys())
return all_connections_keys
def hierarchy(self):
WidgetHierarchy.print_widget_hierarchy(self, only_bec_widgets=True)
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
print(id(app))
# app = BECApplication(sys.argv)
# print(id(app))
main_window = WindowWithUi()
main_window.show()
sys.exit(app.exec())
class UILaunchWindow(BECMainWindow):
RPC = True

View File

@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = False
RPC = True
def __init__(
self,

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,20 @@ from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(BECWidget, QWidget):
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
"""
Display a position within a defined range, e.g. motor limits.
"""
USER_ACCESS = [
"set_value",
"set_range",
"vertical",
"vertical.setter",
"indicator_width",
"indicator_width.setter",
"rounded_corners",
"rounded_corners.setter",
]
PLUGIN = True
ICON_NAME = "horizontal_distribute"
@@ -205,6 +218,12 @@ class PositionIndicator(BECWidget, QWidget):
@Slot(int)
@Slot(float)
def set_value(self, position: float):
"""
Set the position of the indicator
Args:
position: The new position of the indicator
"""
self.position = position
self.update()

View File

@@ -40,6 +40,7 @@ class DeviceUpdateUIComponents(TypedDict):
stop: QPushButton
tweak_increase: QPushButton
tweak_decrease: QPushButton
units: QLabel
class PositionerBoxBase(BECWidget, CompactPopupWidget):
@@ -84,16 +85,33 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
limit_update: Callable[[tuple[float, float]], None],
):
"""Init the device view and readback"""
if self._check_device_is_valid(device):
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
if not self._check_device_is_valid(device):
return
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
ui = self._device_ui_components(device)
if not ui.get("units"):
return
try:
egu = f"[{self.dev[device].egu()}]"
except Exception:
egu = ""
if egu:
ui["units"].setVisible(True)
ui["units"].setText(egu)
else:
ui["units"].setVisible(False)
def _stop_device(self, device: str):
"""Stop call"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
""" Module for a PositionerGroup widget to control a positioner device."""
"""Module for a PositionerGroup widget to control a positioner device."""
from __future__ import annotations

View File

@@ -82,7 +82,7 @@ class DeviceInputBase(BECWidget):
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
@@ -90,7 +90,9 @@ class DeviceInputBase(BECWidget):
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.get_bec_shortcuts()
self._device_filter = []
self._readout_filter = []
@@ -395,7 +397,7 @@ class DeviceInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
dev = getattr(self.dev, device, None)
if dev is None:
raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device."

View File

@@ -1,10 +1,11 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import Signal
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Slot
from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -36,18 +37,20 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
def __init__(
self,
client=None,
config: DeviceSignalInputBaseConfig | dict | None = None,
gui_id: str = None,
**kwargs,
):
self.config = self._process_config_input(config)
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
self._device = None
self.get_bec_shortcuts()
self._signal_filter = []
self._signal_filter = set()
self._signals = []
self._hinted_signals = []
self._normal_signals = []
@@ -58,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
### Qt Slots ###
@Slot(str)
@SafeSlot(str)
def set_signal(self, signal: str):
"""
Set the signal.
@@ -74,10 +77,10 @@ class DeviceSignalInputBase(BECWidget):
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
)
@Slot(str)
@SafeSlot(str)
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happpens
Set the device. If device is not valid, device will be set to None which happens
Args:
device(str): device name.
@@ -88,8 +91,8 @@ class DeviceSignalInputBase(BECWidget):
self._device = device
self.update_signals_from_filters()
@Slot(dict, dict)
@Slot()
@SafeSlot(dict, dict)
@SafeSlot()
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
@@ -102,10 +105,7 @@ class DeviceSignalInputBase(BECWidget):
"""
self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
@@ -113,30 +113,25 @@ class DeviceSignalInputBase(BECWidget):
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
FilterIO.set_selection(widget=self, selection=[self._device])
self._hinted_signals = [self._device]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device_info = device._info["signals"]
if Kind.hinted in self.signal_filter:
hinted_signals = [
device_info = device._info.get("signals", {})
def _update(kind: Kind):
return [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
self._hinted_signals = hinted_signals
if Kind.normal in self.signal_filter:
normal_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
]
self._normal_signals = normal_signals
if Kind.config in self.signal_filter:
config_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.config.value))
]
self._config_signals = config_signals
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
self._config_signals = _update(Kind.config)
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals)
@@ -164,9 +159,9 @@ class DeviceSignalInputBase(BECWidget):
@include_hinted_signals.setter
def include_hinted_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.hinted)
self._signal_filter.add(Kind.hinted)
else:
self._signal_filter.remove(Kind.hinted)
self._signal_filter.discard(Kind.hinted)
self.update_signals_from_filters()
@Property(bool)
@@ -177,9 +172,9 @@ class DeviceSignalInputBase(BECWidget):
@include_normal_signals.setter
def include_normal_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.normal)
self._signal_filter.add(Kind.normal)
else:
self._signal_filter.remove(Kind.normal)
self._signal_filter.discard(Kind.normal)
self.update_signals_from_filters()
@Property(bool)
@@ -190,9 +185,9 @@ class DeviceSignalInputBase(BECWidget):
@include_config_signals.setter
def include_config_signals(self, value: bool):
if value:
self._signal_filter.append(Kind.config)
self._signal_filter.add(Kind.config)
else:
self._signal_filter.remove(Kind.config)
self._signal_filter.discard(Kind.config)
self.update_signals_from_filters()
### Properties and Methods ###
@@ -250,7 +245,7 @@ class DeviceSignalInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device.lower(), None)
dev = getattr(self.dev, device, None)
if dev is None:
logger.warning(f"Device {device} not found in devicemanager.")
return None
@@ -279,3 +274,8 @@ class DeviceSignalInputBase(BECWidget):
if signal in self.signals:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)

View File

@@ -22,10 +22,14 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices"]
ICON_NAME = "list_alt"
PLUGIN = True
@@ -140,7 +144,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.update()

View File

@@ -24,11 +24,15 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and ReadoutPriority. Check DeviceInputBase for more details.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
device_selected = Signal(str)
device_config_update = Signal()
@@ -51,7 +55,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
**kwargs,
):
self._callback_id = None
self._is_valid_input = False
self.__is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.completer = QCompleter(self)
@@ -95,6 +99,20 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
self.textChanged.connect(self.check_validity)
self.check_validity(self.text())
@property
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
return self.__is_valid_input
@_is_valid_input.setter
def _is_valid_input(self, value: bool) -> None:
self.__is_valid_input = value
def on_device_update(self, action: str, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
@@ -147,7 +165,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text.lower())
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.update()

View File

@@ -1,11 +1,13 @@
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBase,
DeviceSignalInputBaseConfig,
)
@@ -23,8 +25,11 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_signal", "set_device", "signals"]
ICON_NAME = "list_alt"
PLUGIN = True
RPC = True
device_signal_changed = Signal(str)
@@ -32,7 +37,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self,
parent=None,
client=None,
config: DeviceSignalInputBase = None,
config: DeviceSignalInputBaseConfig | None = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
@@ -62,9 +67,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if default is not None:
self.set_signal(default)
def update_signals_from_filters(self):
@SafeSlot()
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the combobox"""
super().update_signals_from_filters()
super().update_signals_from_filters(content, metadata)
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
@@ -81,7 +90,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@Slot(str)
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name.

View File

@@ -24,9 +24,12 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"]
device_signal_changed = Signal(str)
PLUGIN = True
RPC = True
ICON_NAME = "vital_signs"
def __init__(
@@ -41,7 +44,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
arg_name: str | None = None,
**kwargs,
):
self._is_valid_input = False
self.__is_valid_input = False
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)
@@ -65,8 +68,22 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
self.set_device(device)
if default is not None:
self.set_signal(default)
self.textChanged.connect(self.validate_device)
self.validate_device(self.text())
self.textChanged.connect(self.check_validity)
self.check_validity(self.text())
@property
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
return self.__is_valid_input
@_is_valid_input.setter
def _is_valid_input(self, value: bool) -> None:
self.__is_valid_input = value
def get_current_device(self) -> object:
"""
@@ -131,6 +148,9 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
DeviceComboBox,
)
app = QApplication([])
set_theme("dark")
@@ -138,6 +158,12 @@ if __name__ == "__main__": # pragma: no cover
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(SignalLineEdit(device="samx"))
device_line_edit = DeviceComboBox()
device_line_edit.filter_to_positioner = True
signal_line_edit = SignalLineEdit()
device_line_edit.device_selected.connect(signal_line_edit.set_device)
layout.addWidget(device_line_edit)
layout.addWidget(signal_line_edit)
widget.show()
app.exec_()

View File

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

View File

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

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