1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-05-09 08:12:15 +02:00

Compare commits

...

238 Commits

Author SHA1 Message Date
semantic-release 2b75d5600a 2.45.13
Automatically generated by python-semantic-release
2025-12-17 13:52:45 +00:00
wakonig_k 01c6e092b9 fix(scan queue): adjustments for changes to the pydantic model of the scan queue 2025-12-17 14:51:54 +01:00
semantic-release ca6f355aac 2.45.12
Automatically generated by python-semantic-release
2025-12-16 12:37:06 +00:00
wyzula_j d876ca72bc fix(heatmap): more robust logic for fast and slow axis in grid scan 2025-12-16 13:36:17 +01:00
wyzula_j e0fd97616d fix(heatmap): flush image if config changes during scan 2025-12-16 13:36:17 +01:00
wyzula_j 6af8a5cbfe fix(heatmap): grid scan image correctly map to scan positions 2025-12-16 13:36:17 +01:00
semantic-release 944e2cedf8 2.45.11
Automatically generated by python-semantic-release
2025-12-15 14:48:20 +00:00
wyzula_j cd11a6cce3 fix(waveform): support for AsyncMultiSignal 2025-12-15 15:47:26 +01:00
semantic-release c98106e594 2.45.10
Automatically generated by python-semantic-release
2025-12-10 11:21:40 +00:00
wakonig_k 04f1ff4fe7 fix(devices): minor fix to comply with new config helper in bec_lib 2025-12-10 12:20:48 +01:00
semantic-release 45ed92494c 2.45.9
Automatically generated by python-semantic-release
2025-12-09 14:21:27 +00:00
wakonig_k 5fc96bd299 fix(rpc): add expiration to GUI registry state updates 2025-12-09 15:20:42 +01:00
semantic-release 1ad5df57fe 2.45.8
Automatically generated by python-semantic-release
2025-12-08 14:36:13 +00:00
wyzula_j 440e778162 fix(notification_banner): backwards compatibility to push messages from Broker to Centre as dict 2025-12-08 15:35:23 +01:00
wyzula_j fdeb8fcb0f fix(notification_banner): formatted error messages fetched directly from BECMessage; do not repreat notifications ids 2025-12-08 15:35:23 +01:00
wyzula_j 5c90983dd4 fix(notification_banner): better contrast in light mode 2025-12-08 15:35:23 +01:00
wyzula_j 4171de1e45 fix(notification_banner): expired messages are hidden in notification center but still accessible 2025-12-08 15:35:23 +01:00
semantic-release f12339e6f9 2.45.7
Automatically generated by python-semantic-release
2025-12-08 14:04:02 +00:00
perl_d ce8e5f0bec fix: handle none in literal combobox 2025-12-08 15:03:17 +01:00
semantic-release 7ea9ab5175 2.45.6
Automatically generated by python-semantic-release
2025-11-27 09:43:46 +00:00
wyzula_j b72f0dc6e8 fix(curve): update dap curves if data are set manually 2025-11-27 10:42:58 +01:00
semantic-release cb9d429884 2.45.5
Automatically generated by python-semantic-release
2025-11-26 13:25:28 +00:00
perl_d 0a80bd0a92 fix: remove ghost widgets in scan metadata 2025-11-26 14:24:36 +01:00
semantic-release 9bc9d355e2 2.45.4
Automatically generated by python-semantic-release
2025-11-24 13:30:46 +00:00
wyzula_j 7d5e702a11 fix(web_links): fixed link to bec widget issues from gitlab to github 2025-11-24 14:29:56 +01:00
wyzula_j 40cbf7fe4f fix(main_window): removed hiding scan progressbar animation 2025-11-24 14:29:56 +01:00
semantic-release 7b287c45f2 2.45.3
Automatically generated by python-semantic-release
2025-11-17 19:27:38 +00:00
wakonig_k c9455672b5 fix(fakeredis): add support for additional args 2025-11-17 20:24:44 +01:00
semantic-release 7f06375f9d 2.45.2
Automatically generated by python-semantic-release
2025-11-17 12:30:21 +00:00
wyzula_j d00d786399 fix(test): removed duplicate test in crosshair 2025-11-17 13:29:35 +01:00
wyzula_j a4c465dcaf build: pyqtgraph pin to 0.13.7 2025-11-17 13:29:35 +01:00
semantic-release d0e94d0da4 2.45.1
Automatically generated by python-semantic-release
2025-11-14 14:13:05 +00:00
wyzula_j bb3cea7fe8 fix(waveform): async_readback can accept 0D data 2025-11-14 15:12:14 +01:00
semantic-release 3c6aa8e138 2.45.0
Automatically generated by python-semantic-release
2025-11-10 19:28:18 +00:00
wyzula_j 198684c65d feat(waveform): dap curve can be attached to custom and history curves 2025-11-10 20:27:31 +01:00
wakonig_k 617f2df2af chore: add third-party license notice 2025-11-10 13:52:22 +01:00
semantic-release ef83287126 2.44.0
Automatically generated by python-semantic-release
2025-11-05 21:43:46 +00:00
wyzula_j d5e6f095fe refactor(plot_base): consolidated user access for the PlotBase 2025-11-05 22:42:57 +01:00
wyzula_j b10efc0f40 feat(plot_base): invert x/y axis 2025-11-05 22:42:57 +01:00
wyzula_j 44b1dbf911 docs: README rewritten 2025-11-03 14:59:57 +01:00
Klaus Wakonig e9d381a18a chore: Update stale issue and PR settings to 120 days 2025-11-03 14:46:03 +01:00
semantic-release b005542df3 2.43.0
Automatically generated by python-semantic-release
2025-10-30 07:58:54 +00:00
wakonig_k 13a9175ba5 feat: add pdf viewer widget 2025-10-30 08:58:11 +01:00
semantic-release 3f8e60a14f 2.42.1
Automatically generated by python-semantic-release
2025-10-28 14:48:23 +00:00
wyzula_j 6bc1c3c5f1 fix(rpc_server): raise window, even if minimized 2025-10-28 15:47:37 +01:00
semantic-release 9f91eb2e08 2.42.0
Automatically generated by python-semantic-release
2025-10-21 13:17:23 +00:00
wyzula_j 1e19092319 feat(positioner_box_2d): added properties to enable/disable vertical and horizontal controls 2025-10-21 15:16:24 +02:00
wyzula_j 96664c3923 feat(image_roi): enhance get_coordinates to include rectangle center and dimensions 2025-10-21 15:16:01 +02:00
semantic-release 741ca2fd8a 2.41.1
Automatically generated by python-semantic-release
2025-10-15 11:25:47 +00:00
wyzula_j 3941050883 fix(dependencies): bec lib versions fixed 2025-10-15 13:25:01 +02:00
semantic-release 1d746c6829 2.41.0
Automatically generated by python-semantic-release
2025-10-15 10:36:45 +00:00
wyzula_j ef27de40ce fix(image_roi): delete button added to compact version 2025-10-15 12:35:51 +02:00
wyzula_j 37df95ead8 fix(image_roi): rois can be removed with right click context menu 2025-10-15 12:35:51 +02:00
wyzula_j c87a6cfce9 feat(image_roi_tree): compact mode added 2025-10-15 12:35:51 +02:00
wakonig_k 3d807eaa63 refactor(serializer): upgrade to new serializer interface 2025-10-13 16:11:47 +02:00
wyzula_j 28ac9c5cc3 build(bec_lib): version bump to 3.69.3 2025-10-09 15:36:18 +02:00
appel_c 1dd20d5986 test(deviceconfig-form-update): Add onFailure default to test 2025-10-09 15:36:18 +02:00
semantic-release 13299aeeb3 2.40.0
Automatically generated by python-semantic-release
2025-10-08 11:41:33 +00:00
wyzula_j d681ba538b fix(waveform): cleanup of scan_history dialog if not closed manually before widget 2025-10-08 13:40:48 +02:00
wyzula_j 2bf489600e fix(waveform): safeguard for _scan_history_closed 2025-10-08 13:40:48 +02:00
wyzula_j 7e88a002b6 fix(waveform): safeguard for if scan_item is a list 2025-10-08 13:40:48 +02:00
wyzula_j 20a59af648 fix(curve_tree): scans are always fetched by scan ids 2025-10-08 13:40:48 +02:00
wyzula_j 540cfc37be fix(waveform): safeguard added to the fetching history data 2025-10-08 13:40:48 +02:00
wyzula_j e59f27a22d fix(waveform): if scan id and scan number is provided, the scan is fetched from the scan id 2025-10-08 13:40:48 +02:00
wyzula_j df8065ea40 fix(curve_tree): safeguard fetching scan numbers from BEC client 2025-10-08 13:40:48 +02:00
wyzula_j 2f3dc2ce6b build(bec_lib): bec_lib dependency raised to 3.68 2025-10-08 13:40:48 +02:00
wyzula_j a006f95f21 test(plotting_framework_e2e): fetching history curve 2025-10-08 13:40:48 +02:00
wyzula_j 8111a4a21b fix(curve_tree): fetching scan numbers directly from the bec client 2025-10-08 13:40:48 +02:00
wyzula_j 962ab774e6 fix(waveform): fetching scan number is not done from list but from .get_by_scan_number 2025-10-08 13:40:48 +02:00
wyzula_j 2f798be7b0 refactor(test_waveform): test waveform renamed 2025-10-08 13:40:48 +02:00
wyzula_j 5a5d32312b test(waveform,curve_tree): test extended to cover history curve behaviour 2025-10-08 13:40:48 +02:00
wyzula_j 0844a9e119 test(conftest): suppress_message_box for error popups fixture autouse True 2025-10-08 13:40:48 +02:00
wyzula_j db7dd4f8d4 fix(waveform): x_data checked with is scalar instead of len() 2025-10-08 13:40:48 +02:00
wyzula_j f083dff612 feat(waveform): new type of curve - history curve 2025-10-08 13:40:48 +02:00
wyzula_j 4be70580a6 refactor(waveform): separate method to fetch scan item from history 2025-10-08 13:40:48 +02:00
wyzula_j d19001c94e fix(waveform): update x suffix label with x property change, do not wait for next update cycle 2025-10-08 13:40:48 +02:00
wakonig_k f25f86522f chore: add dependabot config 2025-10-07 11:12:10 +02:00
semantic-release 948283bc13 2.39.1
Automatically generated by python-semantic-release
2025-10-07 09:00:10 +00:00
wakonig_k 50696bce4c fix: explicitly pass the cached readout flag 2025-10-07 10:59:22 +02:00
semantic-release 1d988a4c57 2.39.0
Automatically generated by python-semantic-release
2025-09-24 16:28:40 +00:00
wyzula_j 565c0bd1e7 feat(rpc_base): windows can be raised to front from CLI 2025-09-24 11:27:47 -05:00
wakonig_k 975404f483 fix(rpc): fix hide/show 2025-09-24 11:27:47 -05:00
semantic-release 165e5e7d84 2.38.4
Automatically generated by python-semantic-release
2025-09-23 15:05:34 +00:00
wakonig_k 108ddae6ca fix(image): add support for specifying preview signals through cli 2025-09-23 17:01:00 +02:00
semantic-release 9737acad58 2.38.3
Automatically generated by python-semantic-release
2025-09-23 14:19:21 +00:00
wakonig_k 65bc5f5421 fix(ringprogressbar): fix client signature 2025-09-23 16:18:33 +02:00
wakonig_k 475ca9f2d8 fix(connector): only flush pending events 2025-09-23 16:18:33 +02:00
wakonig_k bbb5fc6ce1 fix(ringprogressbar): various fixes and improvements 2025-09-23 16:18:33 +02:00
wakonig_k b1b6c5e6a5 test(ringprogressbar): extend e2e test 2025-09-23 16:18:33 +02:00
perl_d 3e339348dd chore: deprecate 3.10, add 3.13 2025-09-15 13:48:32 +02:00
semantic-release 4f075151d5 2.38.2
Automatically generated by python-semantic-release
2025-09-11 15:01:23 +00:00
wyzula_j 0a24ac2c40 fix(waveform):autorange on scan_status 2025-09-11 16:59:35 +02:00
wyzula_j 3a2ec9f1b7 test(crosshair): visibility test added with plotbase fixture 2025-09-11 16:59:35 +02:00
wyzula_j 4dc4ede1d2 fix(plot_base): crosshair items are excluded from visible curves and from auto_range 2025-09-11 16:59:35 +02:00
wyzula_j 556832fd48 fix(waveform): changing curve visibility refresh markers 2025-09-11 16:59:35 +02:00
wyzula_j 72b6f74252 fix(crosshair): ignore fetching data and markers from invisible items 2025-09-11 16:59:35 +02:00
wyzula_j b703b37bbd fix(plot_base): visible items injected into plot item 2025-09-11 16:59:35 +02:00
wakonig_k 18ef35f22a docs: move to autoapi 2025-09-10 15:05:54 +02:00
wakonig_k fe67a4f325 ci: fix stale issues job permissions; add workflow dispatch option 2025-08-31 09:59:16 +02:00
semantic-release f1c3d77a45 2.38.1
Automatically generated by python-semantic-release
2025-08-22 10:06:47 +00:00
perl_d ad7cdc60dd fix: move thefuzz dependency to prod 2025-08-22 12:06:01 +02:00
semantic-release ba047fd776 2.38.0
Automatically generated by python-semantic-release
2025-08-19 15:12:14 +00:00
appel_c 6e05157abb feat(device_manager): DeviceManager view of config session 2025-08-19 17:11:24 +02:00
semantic-release f4bc759e72 2.37.0
Automatically generated by python-semantic-release
2025-08-19 14:52:20 +00:00
wakonig_k 1bec9bd9b2 feat: add explorer widget 2025-08-19 16:51:38 +02:00
semantic-release 8b013d5dce 2.36.0
Automatically generated by python-semantic-release
2025-08-18 10:45:14 +00:00
wakonig_k f2e5a85e61 feat(scan control): add support for literals 2025-08-18 12:44:29 +02:00
semantic-release a2f8880459 2.35.0
Automatically generated by python-semantic-release
2025-08-14 07:16:53 +00:00
wyzula_j 926d722955 feat(property_manager): property manager widget 2025-08-14 09:16:04 +02:00
wyzula_j 44ba7201b4 build: PySide6 upgraded to 6.9.0 2025-08-12 19:56:29 +02:00
semantic-release 0717426db2 2.34.0
Automatically generated by python-semantic-release
2025-08-07 13:39:47 +00:00
perl_d f4af6ebc5f fix: use better source for plugin repo name 2025-08-07 15:39:07 +02:00
perl_d a923f12c97 feat: autoformat compiled file and add docs 2025-08-07 15:39:07 +02:00
perl_d a5a7607a83 tests: add tests for widget creator 2025-08-07 15:39:07 +02:00
perl_d 9de548446b fix: plugin widget import machinery
- lazy import client so plugin widgets can import BECWidgets which use
  it indirectly
- exclude classes originating from bec_widgets core from plugin
  discovery
- better errors
2025-08-07 15:39:07 +02:00
perl_d 49ac7decf7 feat(plugin manager): add cli commands 2025-08-07 15:39:07 +02:00
semantic-release 092bed38fa 2.33.3
Automatically generated by python-semantic-release
2025-07-31 11:10:38 +00:00
appel_c 50c84a766a refactor(scan-history): add spinner for loading time of history 2025-07-31 13:09:47 +02:00
appel_c d22a3317ba refactor: use client callback for scan history reload 2025-07-31 13:09:47 +02:00
appel_c 6df1d0c31f fix(scan-history-view): account for async loading of scan history 2025-07-31 13:09:47 +02:00
appel_c 946752a4b0 refactor(scan-history): fix insert logic; cleanup 2025-07-31 13:09:47 +02:00
appel_c c1f62ad6cb refactor: make ids a set, cleanup 2025-07-31 13:09:47 +02:00
appel_c a5adf3a97d refactor: improve scan history performance on loading full scan lists 2025-07-31 13:09:47 +02:00
semantic-release 76e3e0b60f 2.33.2
Automatically generated by python-semantic-release
2025-07-31 07:27:50 +00:00
perl_d f18eeb9c5d fix: don't warn on empty DeviceEdit init 2025-07-31 09:26:59 +02:00
perl_d 32ce8e2818 fix: remove config, directly set device+signal 2025-07-31 09:26:59 +02:00
perl_d 23413cffab fix: delete choice dialog on close 2025-07-31 09:26:59 +02:00
David Perl 4bbb8fa519 fix: display short lists in SignalDisplay 2025-07-31 09:26:59 +02:00
semantic-release a972369a72 2.33.1
Automatically generated by python-semantic-release
2025-07-31 06:50:30 +00:00
wakonig_k cd81e7f9ba fix(cli): ensure guis are not started twice 2025-07-31 08:49:48 +02:00
semantic-release e2b8118f67 2.33.0
Automatically generated by python-semantic-release
2025-07-29 13:24:20 +00:00
wakonig_k 5f925ba4e3 build: update bec and qtmonaco min dependencies 2025-07-29 15:23:36 +02:00
wakonig_k fc68d2cf2d feat(monaco): add insert, delete and lsp header 2025-07-29 15:23:36 +02:00
wakonig_k 627b49b33a feat(monaco): add vim mode 2025-07-29 15:23:36 +02:00
wakonig_k a51ef04cdf fix(monaco): forward text changed signal 2025-07-29 15:23:36 +02:00
wakonig_k 40f4bce285 test(web console): add tests for the web console 2025-07-29 15:23:36 +02:00
wakonig_k 2b9fe6c959 feat(web console): add signal to indicate when the js backend is initialized 2025-07-29 15:23:36 +02:00
wakonig_k c2e16429c9 feat(web console): add set_readonly method 2025-07-29 15:23:36 +02:00
semantic-release 85ce2aa136 2.32.0
Automatically generated by python-semantic-release
2025-07-29 13:09:07 +00:00
wakonig_k fd5af01842 feat(dock area): add screenshot toolbar action 2025-07-29 15:08:17 +02:00
wakonig_k 8a214c8978 feat(rpc_timeout): add decorator to override the rpc timeout 2025-07-29 15:08:17 +02:00
semantic-release f3214445f2 2.31.3
Automatically generated by python-semantic-release
2025-07-29 12:57:40 +00:00
wyzula_j 6bf84aea25 fix(waveform): fallback mechanism for auto mode to use index if scan_report_devices are not available 2025-07-29 14:56:54 +02:00
semantic-release aace071f11 2.31.2
Automatically generated by python-semantic-release
2025-07-29 12:05:13 +00:00
wakonig_k bf86a030a0 fix(bec widgets): always call cleanup of child widgets on cleanup 2025-07-29 14:04:24 +02:00
semantic-release 358c979bf2 2.31.1
Automatically generated by python-semantic-release
2025-07-29 09:19:55 +00:00
wakonig_k c1bdc506e8 fix(image_base): fix cleanup of uninitialized image layer 2025-07-29 11:19:07 +02:00
semantic-release 4febfb79df 2.31.0
Automatically generated by python-semantic-release
2025-07-29 07:02:55 +00:00
wyzula_j 0854175acb test(launch_window): MainWindow raise test removed, features is supported now 2025-07-29 09:01:01 +02:00
wyzula_j e090ac49b7 fix(launch_window): logic for custom main window apps adjusted 2025-07-29 09:01:01 +02:00
wyzula_j e4521d9528 feat(bec_main_window): plugin and rpc created 2025-07-29 09:01:01 +02:00
wyzula_j 1d0490fff4 fix(bec_main_window): main window have unified status bar on macOS 2025-07-29 09:01:01 +02:00
wyzula_j 10cbb9a05c refactor(widgets): all plugins regenerated 2025-07-29 09:01:01 +02:00
wyzula_j 7073e75adf fix(scan_progressbar): added kwargs to init 2025-07-29 09:01:01 +02:00
wyzula_j e42ffd7c01 fix(color_button_native): removed BECWidget inheritance 2025-07-29 09:01:01 +02:00
wyzula_j 2bd6d00899 fix(decimal_spinbox): removed BECWidget inheritance 2025-07-29 09:01:01 +02:00
wyzula_j c2a918ef4b fix(plugin_utils): plugins can be created from QWidgets, no need for BECWidget base class for plugin creation 2025-07-29 09:01:01 +02:00
wyzula_j 6bbf5126cf fix(widgets): added missing __init__ files 2025-07-29 09:01:01 +02:00
wyzula_j 728d4efd96 fix(utils): plugin template createWidget do not initialise widgets by default 2025-07-29 09:01:01 +02:00
semantic-release 7926969996 2.30.6
Automatically generated by python-semantic-release
2025-07-26 12:44:29 +00:00
wyzula_j 61e5bde15f fix(waveform): autorange is applied with 150ms delay after curve is added 2025-07-26 14:43:51 +02:00
semantic-release c8aa770de3 2.30.5
Automatically generated by python-semantic-release
2025-07-25 17:44:39 +00:00
appel_c 4d5df9608a refactor(positioner-box): cleanup, accept float precision 2025-07-25 19:43:52 +02:00
appel_c b718b438ba fix(positioner-box): Test to fix handling of none integer values for precision 2025-07-25 19:43:52 +02:00
semantic-release 2f978c93c4 2.30.4
Automatically generated by python-semantic-release
2025-07-25 10:18:28 +00:00
wakonig_k b4e0664011 fix(cli): remove stderr from cli output when not using rpc 2025-07-25 12:17:44 +02:00
semantic-release 45fbf4015d 2.30.3
Automatically generated by python-semantic-release
2025-07-23 08:01:36 +00:00
David Perl 0d81bdd4dd fix: cleanup subscriptions in device browser 2025-07-23 10:00:43 +02:00
semantic-release bb4c30ad80 2.30.2
Automatically generated by python-semantic-release
2025-07-23 06:57:35 +00:00
wyzula_j 3fd09fceef test(test_plotting_framework_e2e): added test for waveform with passing device from dev container 2025-07-23 08:56:52 +02:00
perl_d 8eb8225a7f fix: factor out device name function and add test 2025-07-23 08:56:52 +02:00
wyzula_j 491d04467c fix(rpc_base): rpc_call wrapper passes full_name for Devices indeed of name 2025-07-23 08:56:52 +02:00
semantic-release 3bcff75107 2.30.1
Automatically generated by python-semantic-release
2025-07-22 18:19:10 +00:00
perl_d 608590c542 fix: ignore KeyError in SignalLabel 2025-07-22 20:18:28 +02:00
semantic-release 012f7cf970 2.30.0
Automatically generated by python-semantic-release
2025-07-22 14:24:47 +00:00
perl_d cd17a4aad9 fix(signal_label): rewrite reading selection logic 2025-07-22 15:24:03 +01:00
perl_d f0dc992586 fix(signal_label): use read() instead of get() for init 2025-07-22 15:24:03 +01:00
perl_d fd1f9941e0 chore: update client.py 2025-07-22 15:24:03 +01:00
perl_d 3384ca02bd fix(device_browser): display signal for signals 2025-07-22 15:24:03 +01:00
perl_d 959cedbbd5 fix(signal_label): update signal from dialog correctly 2025-07-22 15:24:03 +01:00
perl_d ca4f97503b feat(signal_label): property to display array data or not 2025-07-22 15:24:03 +01:00
perl_d 22beadcad0 fix(signal_label): show all signals by default 2025-07-22 15:24:03 +01:00
perl_d b9af36a4f1 fix(device_signal_display): don't read omitted 2025-07-22 15:24:03 +01:00
semantic-release bdff736aa2 2.29.0
Automatically generated by python-semantic-release
2025-07-22 11:39:06 +00:00
wyzula_j 7cda2ed846 refactor(notification_banner): BECNotificationBroker done as singleton to sync all windows in the session 2025-07-22 13:38:23 +02:00
wyzula_j cd9d22d0b4 feat(notification_banner): notification centre for alarms implemented into BECMainWindow 2025-07-22 13:38:23 +02:00
semantic-release 37b80e16a0 2.28.0
Automatically generated by python-semantic-release
2025-07-21 12:23:48 +00:00
perl_d 7f0098f153 feat: save and load config from devicebrowser 2025-07-21 14:23:01 +02:00
perl_d 8489ef4a69 feat: remove and readd device for config changes 2025-07-21 14:23:01 +02:00
perl_d 13976557fb feat: disable editing while scan active 2025-07-21 14:23:01 +02:00
semantic-release 06ad87ce0a 2.27.1
Automatically generated by python-semantic-release
2025-07-17 13:22:03 +00:00
wyzula_j 00e3713181 fix(image_roi_tree): rois signals are disconnected when roi tree widget is closed 2025-07-17 15:21:11 +02:00
semantic-release 62020f9965 2.27.0
Automatically generated by python-semantic-release
2025-07-17 13:03:53 +00:00
wakonig_k 2373c7e996 feat: add monaco editor 2025-07-17 15:02:01 +02:00
semantic-release 1f3566c105 2.26.0
Automatically generated by python-semantic-release
2025-07-17 12:44:47 +00:00
wakonig_k b8ae7b2e96 fix(config label): reset offset when toggling the label action 2025-07-17 14:44:06 +02:00
wakonig_k 23674ccf59 fix(performance_bundle): fix performance bundle cleanup 2025-07-17 14:44:06 +02:00
wakonig_k 1d8069e391 feat(heatmap): add interpolation and oversampling UI components 2025-07-17 14:44:06 +02:00
wakonig_k 44cc06137c test(history): add history message helper methods to conftest 2025-07-17 14:44:06 +02:00
wakonig_k 46a91784d2 refactor(image_base): cleanup 2025-07-17 14:44:06 +02:00
wakonig_k debd347b64 feat(device combobox): add option to insert an empty element 2025-07-17 14:44:06 +02:00
semantic-release a13c3c44c8 2.25.0
Automatically generated by python-semantic-release
2025-07-17 09:27:51 +00:00
appel_c 25b2737aac refactor: cleanup, add compact popup view for scan_history_browser and update tests 2025-07-17 11:26:57 +02:00
appel_c cf97cc1805 refactor: add additional components for history metadata, device view and popup ui 2025-07-17 11:26:57 +02:00
appel_c 694a6c4960 fix(bec-progressbar): add flag for theme update 2025-07-17 11:26:57 +02:00
wyzula_j 9caae4cf40 feat(scan-history-browser): Add history browser and history metadata viewer 2025-07-17 11:26:57 +02:00
appel_c 2b06e34ecf ci(plugin): add plugin repository test to BW ci 2025-07-15 15:09:53 +02:00
appel_c a9c8995ac0 ci(bec): add child_repos test for bec (unit and e2e tests) 2025-07-15 15:09:53 +02:00
semantic-release 1262c66fd6 2.24.1
Automatically generated by python-semantic-release
2025-07-15 09:24:58 +00:00
perl_d bde523806f fix: update signal label for device_edit changes 2025-07-15 11:24:12 +02:00
semantic-release 16bca25d9c 2.24.0
Automatically generated by python-semantic-release
2025-07-15 08:30:13 +00:00
perl_d 130cc24b35 feat(device_browser): connect update to item refresh 2025-07-15 10:29:31 +02:00
perl_d 8b2d6052e8 fix(device_browser): un-nest exception 2025-07-15 10:29:31 +02:00
perl_d 530797a556 fix: hide validity LED, show message as tooltip 2025-07-15 10:29:31 +02:00
perl_d c660e5141f fix: validate some config data 2025-07-15 10:29:31 +02:00
perl_d 900153bc0b feat(#495): add validation against existing device names 2025-07-15 10:29:31 +02:00
perl_d 8dc72656ef feat(device_browser): device deletion from config 2025-07-15 10:29:31 +02:00
perl_d 170be0c7d3 feat: (#495) add devices through browser 2025-07-15 10:29:31 +02:00
perl_d 1925e6ac7f docs: docstring for config dialog 2025-07-15 10:29:31 +02:00
semantic-release b6cef2d27b 2.23.0
Automatically generated by python-semantic-release
2025-07-11 16:44:57 +00:00
wyzula_j a9fce175b7 feat(widget_finder): widget to fetch any other widget by class from currently running app 2025-07-11 18:44:08 +02:00
wyzula_j 783d042e8c feat(widget_io): utility function to find widget in the app by class 2025-07-11 18:44:08 +02:00
semantic-release 319a4206f2 2.22.2
Automatically generated by python-semantic-release
2025-07-11 12:43:39 +00:00
wyzula_j 76439866c1 fix(plot_base): autorange takes into account only visible curves 2025-07-11 14:42:54 +02:00
semantic-release ca600b057e 2.22.1
Automatically generated by python-semantic-release
2025-07-11 11:57:47 +00:00
wakonig_k 6c494258f8 fix(heatmap): fix pixel size calculation for arbitrary shapes 2025-07-11 13:57:01 +02:00
wakonig_k 63a8da680d fix(crosshair): crosshair mouse_moved can be set manually 2025-07-11 13:57:01 +02:00
semantic-release 0f2bde1a0a 2.22.0
Automatically generated by python-semantic-release
2025-07-10 12:23:05 +00:00
wakonig_k 0c76b0c495 feat: add heatmap widget 2025-07-10 14:22:15 +02:00
wakonig_k e594de3ca3 fix(image): reset crosshair on new scan 2025-07-10 14:22:15 +02:00
wakonig_k adaad4f4d5 fix(crosshair): add slot to reset mouse markers 2025-07-10 14:22:15 +02:00
wakonig_k 39c316d6ea fix(image item): fix processor for nans in images 2025-07-10 14:22:15 +02:00
wakonig_k 3ba0fc4b44 fix(crosshair): fix crosshair support for transformations 2025-07-10 14:22:15 +02:00
wakonig_k a6fc7993a3 fix(image_processor): support for nans in nd arrays 2025-07-10 14:22:15 +02:00
wakonig_k 324a5bd3d9 feat(image_item): add support for qtransform 2025-07-10 14:22:15 +02:00
wakonig_k 8929778f07 fix(image_base): move cbar init to image base 2025-07-10 14:22:15 +02:00
semantic-release 72b5c46912 2.21.4
Automatically generated by python-semantic-release
2025-07-08 09:57:41 +00:00
wyzula_j 244bca4e1e fix(image_roi_tree): changing color dialog from ColorButtonNative is open once 2025-07-08 11:57:00 +02:00
271 changed files with 18057 additions and 1787 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
+64
View File
@@ -0,0 +1,64 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
BEC_CORE_BRANCH:
description: 'Branch for BEC Core'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch for Ophyd Devices'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch for BEC Widgets'
required: false
default: 'main'
type: string
jobs:
bec:
name: BEC Unit Tests
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Install BEC and dependencies
uses: ./.github/actions/bec_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: '3.11'
- name: Run Pytest
run: |
cd ./bec
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
bec-e2e-test:
name: BEC End2End Tests
runs-on: ubuntu-latest
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Run E2E Tests
uses: ./.github/actions/bec_e2e_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
PYTHON_VERSION: '3.11'
+20
View File
@@ -58,3 +58,23 @@ jobs:
needs: [check_pr_status, formatter] needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml uses: ./.github/workflows/end2end-conda.yml
child-repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.11", "3.12"] python-version: ["3.11", "3.12", "3.13"]
env: env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
+8 -4
View File
@@ -2,14 +2,18 @@ name: 'Close stale issues and PRs'
on: on:
schedule: schedule:
- cron: '00 10 * * *' - cron: '00 10 * * *'
workflow_dispatch:
jobs: jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v9
with: with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.' stale-issue-message: 'This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.' stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
days-before-stale: 60 days-before-stale: 120
days-before-close: 7 days-before-close: 14
-289
View File
@@ -1,289 +0,0 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
include:
- template: Security/Secret-Detection.gitlab-ci.yml
- project: "bec/awi_utils"
file: "/templates/check-packages-job.yml"
inputs:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
pip_args: ".[dev]"
# different stages in the pipeline
stages:
- Formatter
- test
- AdditionalTests
- End2End
- Deploy
.install-qt-webengine-deps: &install-qt-webengine-deps
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e ./bec/pytest_bec_e2e
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- *install-qt-webengine-deps
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install -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:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint:
stage: Formatter
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
- echo "Pylint score is $PYLINT_SCORE"
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
needs: []
allow_failure: true
before_script:
- pip install pylint pylint-exit anybadge
- apt-get update
- apt-get install -y bc
script:
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
- echo "Changed Python files:"
- $CHANGED_FILES
# Run pylint only on changed files
- mkdir ./pylint
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
- echo "Pylint score is $PYLINT_SCORE"
# Fail the job if the pylint score is below 9
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- 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:
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
stage: AdditionalTests
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3:25.1.1-2
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- conda config --show-sources
- conda config --add channels conda-forge
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
- conda config --remove channels https://repo.anaconda.com/pkgs/main
- conda config --remove channels https://repo.anaconda.com/pkgs/r
- conda config --show-sources
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11
- conda init bash
- source ~/.bashrc
- conda activate test-environment
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- cd ../
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
artifacts:
when: on_failure
paths:
- ./logs/*.log
expire_in: 1 week
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
semver:
stage: Deploy
needs: ["tests"]
script:
- git config --global user.name "ci_update_bot"
- git config --global user.email "ci_update_bot@bec.ch"
- git checkout "$CI_COMMIT_REF_NAME"
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
# delete all local tags
- git tag -l | xargs git tag -d
- git fetch --tags
- git tag
# build and publish package
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
- semantic-release publish
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
pages:
stage: Deploy
needs: ["semver"]
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: "$CI_COMMIT_TAG != null"
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
+1 -1
View File
@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to # Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint. # the version used to run pylint.
py-version=3.10 py-version=3.11
# When enabled, pylint would attempt to guess common misconfiguration and emit # When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages. # user-friendly hints instead of false-positive error messages.
+923
View File
@@ -1,6 +1,929 @@
# CHANGELOG # CHANGELOG
## v2.45.13 (2025-12-17)
### Bug Fixes
- **scan queue**: Adjustments for changes to the pydantic model of the scan queue
([`01c6e09`](https://github.com/bec-project/bec_widgets/commit/01c6e092b9cd46ae056c43e8c6576f7a570cce80))
## v2.45.12 (2025-12-16)
### Bug Fixes
- **heatmap**: Flush image if config changes during scan
([`e0fd976`](https://github.com/bec-project/bec_widgets/commit/e0fd97616d370722e2ebf12d0f93862ac35cb20d))
- **heatmap**: Grid scan image correctly map to scan positions
([`6af8a5c`](https://github.com/bec-project/bec_widgets/commit/6af8a5cbfe0f97327b31039033d3e6946388347c))
- **heatmap**: More robust logic for fast and slow axis in grid scan
([`d876ca7`](https://github.com/bec-project/bec_widgets/commit/d876ca72bc50f967f0872eb777f2378a3db68ddf))
## v2.45.11 (2025-12-15)
### Bug Fixes
- **waveform**: Support for AsyncMultiSignal
([`cd11a6c`](https://github.com/bec-project/bec_widgets/commit/cd11a6cce33f3c0642984ae6b2d159c7441e22c6))
## v2.45.10 (2025-12-10)
### Bug Fixes
- **devices**: Minor fix to comply with new config helper in bec_lib
([`04f1ff4`](https://github.com/bec-project/bec_widgets/commit/04f1ff4fe7869215f010bf73f7271e063e21f2a2))
## v2.45.9 (2025-12-09)
### Bug Fixes
- **rpc**: Add expiration to GUI registry state updates
([`5fc96bd`](https://github.com/bec-project/bec_widgets/commit/5fc96bd299115c1849240bae3b37112aad8f5a54))
## v2.45.8 (2025-12-08)
### Bug Fixes
- **notification_banner**: Backwards compatibility to push messages from Broker to Centre as dict
([`440e778`](https://github.com/bec-project/bec_widgets/commit/440e778162ebb359fc33be26e3d22f99b4f9dcfe))
- **notification_banner**: Better contrast in light mode
([`5c90983`](https://github.com/bec-project/bec_widgets/commit/5c90983dd4c3ff96e5625ebda0054a1ac1256227))
- **notification_banner**: Expired messages are hidden in notification center but still accessible
([`4171de1`](https://github.com/bec-project/bec_widgets/commit/4171de1e454c4832513ca599c0fd0eaa365c7c32))
- **notification_banner**: Formatted error messages fetched directly from BECMessage; do not repreat
notifications ids
([`fdeb8fc`](https://github.com/bec-project/bec_widgets/commit/fdeb8fcb0f223d64933f2791585756527c2f41ed))
## v2.45.7 (2025-12-08)
### Bug Fixes
- Handle none in literal combobox
([`ce8e5f0`](https://github.com/bec-project/bec_widgets/commit/ce8e5f0bec7643c9f826e06f987775de95abb91d))
## v2.45.6 (2025-11-27)
### Bug Fixes
- **curve**: Update dap curves if data are set manually
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
## v2.45.5 (2025-11-26)
### Bug Fixes
- Remove ghost widgets in scan metadata
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
## v2.45.4 (2025-11-24)
### Bug Fixes
- **main_window**: Removed hiding scan progressbar animation
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
- **web_links**: Fixed link to bec widget issues from gitlab to github
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
## v2.45.3 (2025-11-17)
### Bug Fixes
- **fakeredis**: Add support for additional args
([`c945567`](https://github.com/bec-project/bec_widgets/commit/c9455672b58b9df101ccd0d80a169bdf6c707f34))
## v2.45.2 (2025-11-17)
### Bug Fixes
- **test**: Removed duplicate test in crosshair
([`d00d786`](https://github.com/bec-project/bec_widgets/commit/d00d786399bca516b8030b9de881b674140bf439))
### Build System
- Pyqtgraph pin to 0.13.7
([`a4c465d`](https://github.com/bec-project/bec_widgets/commit/a4c465dcaf8cb03962dec1e360b7b832a9a5c780))
## v2.45.1 (2025-11-14)
### Bug Fixes
- **waveform**: Async_readback can accept 0D data
([`bb3cea7`](https://github.com/bec-project/bec_widgets/commit/bb3cea7fe800cd5375de5351a72e0944dc86861f))
## v2.45.0 (2025-11-10)
### Chores
- Add third-party license notice
([`617f2df`](https://github.com/bec-project/bec_widgets/commit/617f2df2af41db7692c42d0e10bce4968f36fb94))
### Features
- **waveform**: Dap curve can be attached to custom and history curves
([`198684c`](https://github.com/bec-project/bec_widgets/commit/198684c65d9565e8985156b426b8ef98dcc687cc))
## v2.44.0 (2025-11-05)
### Chores
- Update stale issue and PR settings to 120 days
([`e9d381a`](https://github.com/bec-project/bec_widgets/commit/e9d381a18a425727216f035ecccdad25f3189608))
### Documentation
- Readme rewritten
([`44b1dbf`](https://github.com/bec-project/bec_widgets/commit/44b1dbf911f43dbde4286e2ea541c480f7b834be))
### Features
- **plot_base**: Invert x/y axis
([`b10efc0`](https://github.com/bec-project/bec_widgets/commit/b10efc0f400fe36f7cb0d5998214d50943934d7b))
### Refactoring
- **plot_base**: Consolidated user access for the PlotBase
([`d5e6f09`](https://github.com/bec-project/bec_widgets/commit/d5e6f095fe60223972235acd3ea68389aa7a1a14))
## v2.43.0 (2025-10-30)
### Features
- Add pdf viewer widget
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
## v2.42.1 (2025-10-28)
### Bug Fixes
- **rpc_server**: Raise window, even if minimized
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
## v2.42.0 (2025-10-21)
### Features
- **image_roi**: Enhance get_coordinates to include rectangle center and dimensions
([`96664c3`](https://github.com/bec-project/bec_widgets/commit/96664c3923737df0b09aa8f35df388f9fd630b55))
- **positioner_box_2d**: Added properties to enable/disable vertical and horizontal controls
([`1e19092`](https://github.com/bec-project/bec_widgets/commit/1e190923196f8b28c92dfdd83b9ce90873dd792d))
## v2.41.1 (2025-10-15)
### Bug Fixes
- **dependencies**: Bec lib versions fixed
([`3941050`](https://github.com/bec-project/bec_widgets/commit/3941050883a791f800ab7178af2435ac14f837b6))
## v2.41.0 (2025-10-15)
### Bug Fixes
- **image_roi**: Delete button added to compact version
([`ef27de4`](https://github.com/bec-project/bec_widgets/commit/ef27de40ceee8375d95a0f3a8e451b7d05d0ae2c))
- **image_roi**: Rois can be removed with right click context menu
([`37df95e`](https://github.com/bec-project/bec_widgets/commit/37df95ead8d6a07a6c5794a97a486d9f380004cc))
### Build System
- **bec_lib**: Version bump to 3.69.3
([`28ac9c5`](https://github.com/bec-project/bec_widgets/commit/28ac9c5cc369bdfa712c70c45591243631c65066))
### Features
- **image_roi_tree**: Compact mode added
([`c87a6cf`](https://github.com/bec-project/bec_widgets/commit/c87a6cfce9c36588b32f5279e63072bc2646c36f))
### Refactoring
- **serializer**: Upgrade to new serializer interface
([`3d807ea`](https://github.com/bec-project/bec_widgets/commit/3d807eaa63980fd2bb11661696c4d8548fffde8c))
### Testing
- **deviceconfig-form-update**: Add onFailure default to test
([`1dd20d5`](https://github.com/bec-project/bec_widgets/commit/1dd20d5986485f3bfe7ee02596ca23027ec4b756))
## v2.40.0 (2025-10-08)
### Bug Fixes
- **curve_tree**: Fetching scan numbers directly from the bec client
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
- **curve_tree**: Safeguard fetching scan numbers from BEC client
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
- **curve_tree**: Scans are always fetched by scan ids
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
- **waveform**: Safeguard added to the fetching history data
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
- **waveform**: Safeguard for _scan_history_closed
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
- **waveform**: Safeguard for if scan_item is a list
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
- **waveform**: X_data checked with is scalar instead of len()
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
### Build System
- **bec_lib**: Bec_lib dependency raised to 3.68
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
### Chores
- Add dependabot config
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
### Features
- **waveform**: New type of curve - history curve
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
### Refactoring
- **test_waveform**: Test waveform renamed
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
- **waveform**: Separate method to fetch scan item from history
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
### Testing
- **conftest**: Suppress_message_box for error popups fixture autouse True
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
- **plotting_framework_e2e**: Fetching history curve
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
- **waveform,curve_tree**: Test extended to cover history curve behaviour
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
## v2.39.1 (2025-10-07)
### Bug Fixes
- Explicitly pass the cached readout flag
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
## v2.39.0 (2025-09-24)
### Bug Fixes
- **rpc**: Fix hide/show
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
### Features
- **rpc_base**: Windows can be raised to front from CLI
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
## v2.38.4 (2025-09-23)
### Bug Fixes
- **image**: Add support for specifying preview signals through cli
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
## v2.38.3 (2025-09-23)
### Bug Fixes
- **connector**: Only flush pending events
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
- **ringprogressbar**: Fix client signature
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
- **ringprogressbar**: Various fixes and improvements
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
### Chores
- Deprecate 3.10, add 3.13
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
### Testing
- **ringprogressbar**: Extend e2e test
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
## v2.38.2 (2025-09-11)
### Bug Fixes
- **crosshair**: Ignore fetching data and markers from invisible items
([`72b6f74`](https://github.com/bec-project/bec_widgets/commit/72b6f74252e1f36339945c549049b166cccf3561))
- **plot_base**: Crosshair items are excluded from visible curves and from auto_range
([`4dc4ede`](https://github.com/bec-project/bec_widgets/commit/4dc4ede1d251d081e5bcf3d37fcc784982c9258e))
- **plot_base**: Visible items injected into plot item
([`b703b37`](https://github.com/bec-project/bec_widgets/commit/b703b37bbdbf97182b58ac4c69c1384fa78d0c12))
- **waveform**: Changing curve visibility refresh markers
([`556832f`](https://github.com/bec-project/bec_widgets/commit/556832fd48bcb16b95df8cf91417d7045bbca2a3))
### Continuous Integration
- Fix stale issues job permissions; add workflow dispatch option
([`fe67a4f`](https://github.com/bec-project/bec_widgets/commit/fe67a4f325cbd41f13102e5698d86ed9e90b048e))
### Documentation
- Move to autoapi
([`18ef35f`](https://github.com/bec-project/bec_widgets/commit/18ef35f22a1b7496b13f833e63a4f3875e1497e3))
### Testing
- **crosshair**: Visibility test added with plotbase fixture
([`3a2ec9f`](https://github.com/bec-project/bec_widgets/commit/3a2ec9f1b74c4bb5f239940b874576a877ce45c0))
## v2.38.1 (2025-08-22)
### Bug Fixes
- Move thefuzz dependency to prod
([`ad7cdc6`](https://github.com/bec-project/bec_widgets/commit/ad7cdc60dd6da6c5291f8b42932aacb12aa671a6))
## v2.38.0 (2025-08-19)
### Features
- **device_manager**: Devicemanager view of config session
([`6e05157`](https://github.com/bec-project/bec_widgets/commit/6e05157abb61ec569eec10ff7295c28cb6a2ec45))
## v2.37.0 (2025-08-19)
### Features
- Add explorer widget
([`1bec9bd`](https://github.com/bec-project/bec_widgets/commit/1bec9bd9b2238ed484e8d25e691326efe5730f6b))
## v2.36.0 (2025-08-18)
### Features
- **scan control**: Add support for literals
([`f2e5a85`](https://github.com/bec-project/bec_widgets/commit/f2e5a85e616aa76d4b7ad3b3c76a24ba114ebdd1))
## v2.35.0 (2025-08-14)
### Build System
- Pyside6 upgraded to 6.9.0
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
### Features
- **property_manager**: Property manager widget
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
## v2.34.0 (2025-08-07)
### Bug Fixes
- Plugin widget import machinery
([`9de5484`](https://github.com/bec-project/bec_widgets/commit/9de548446b9975c0f692757c66ffa07b9a849f15))
- lazy import client so plugin widgets can import BECWidgets which use it indirectly - exclude
classes originating from bec_widgets core from plugin discovery - better errors
- Use better source for plugin repo name
([`f4af6eb`](https://github.com/bec-project/bec_widgets/commit/f4af6ebc5fabf5b62ec87b580476d93d52690b08))
### Features
- Autoformat compiled file and add docs
([`a923f12`](https://github.com/bec-project/bec_widgets/commit/a923f12c974192909222fcada9eca97325866d74))
- **plugin manager**: Add cli commands
([`49ac7de`](https://github.com/bec-project/bec_widgets/commit/49ac7decf7d4cf461e6437f7285dc6967ee36d96))
## v2.33.3 (2025-07-31)
### Bug Fixes
- **scan-history-view**: Account for async loading of scan history
([`6df1d0c`](https://github.com/bec-project/bec_widgets/commit/6df1d0c31fb58c25b01e95e2247277ff2dd5d00e))
### Refactoring
- Improve scan history performance on loading full scan lists
([`a5adf3a`](https://github.com/bec-project/bec_widgets/commit/a5adf3a97d9ff05cef833445c1e6cd8f35a9a2fa))
- Make ids a set, cleanup
([`c1f62ad`](https://github.com/bec-project/bec_widgets/commit/c1f62ad6cb00d9b392a8e0b6247f5260dfb37256))
- Use client callback for scan history reload
([`d22a331`](https://github.com/bec-project/bec_widgets/commit/d22a3317baeccfcc4e074dcef4e3912301d210c5))
- **scan-history**: Add spinner for loading time of history
([`50c84a7`](https://github.com/bec-project/bec_widgets/commit/50c84a766a2b021768fb2c0e8ee00b8e5f058ba7))
- **scan-history**: Fix insert logic; cleanup
([`946752a`](https://github.com/bec-project/bec_widgets/commit/946752a4b05804c2f59cb5c21e4c1d11709a7d44))
## v2.33.2 (2025-07-31)
### Bug Fixes
- Delete choice dialog on close
([`23413cf`](https://github.com/bec-project/bec_widgets/commit/23413cffabe721e35bb5bb726ec34d74dc4ffe05))
- Display short lists in SignalDisplay
([`4bbb8fa`](https://github.com/bec-project/bec_widgets/commit/4bbb8fa519e8a90eebfcfa34e157493c9baa7880))
- Don't warn on empty DeviceEdit init
([`f18eeb9`](https://github.com/bec-project/bec_widgets/commit/f18eeb9c5dccbd9348b6ee6d1477a8b7925d40fc))
- Remove config, directly set device+signal
([`32ce8e2`](https://github.com/bec-project/bec_widgets/commit/32ce8e2818ceacda87e48399e3ed4df0cabb2335))
## v2.33.1 (2025-07-31)
### Bug Fixes
- **cli**: Ensure guis are not started twice
([`cd81e7f`](https://github.com/bec-project/bec_widgets/commit/cd81e7f9ba40be23f6b930d250f743276720b277))
## v2.33.0 (2025-07-29)
### Bug Fixes
- **monaco**: Forward text changed signal
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
### Build System
- Update bec and qtmonaco min dependencies
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
### Features
- **monaco**: Add insert, delete and lsp header
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
- **monaco**: Add vim mode
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
- **web console**: Add set_readonly method
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
- **web console**: Add signal to indicate when the js backend is initialized
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
### Testing
- **web console**: Add tests for the web console
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
## v2.32.0 (2025-07-29)
### Features
- **dock area**: Add screenshot toolbar action
([`fd5af01`](https://github.com/bec-project/bec_widgets/commit/fd5af0184279400ca6d8e5d2042f31be88d180f3))
- **rpc_timeout**: Add decorator to override the rpc timeout
([`8a214c8`](https://github.com/bec-project/bec_widgets/commit/8a214c897899d0d94d5f262591a001c127d1b155))
## v2.31.3 (2025-07-29)
### Bug Fixes
- **waveform**: Fallback mechanism for auto mode to use index if scan_report_devices are not
available
([`6bf84ae`](https://github.com/bec-project/bec_widgets/commit/6bf84aea2508ff01fe201c045ec055684da88593))
## v2.31.2 (2025-07-29)
### Bug Fixes
- **bec widgets**: Always call cleanup of child widgets on cleanup
([`bf86a03`](https://github.com/bec-project/bec_widgets/commit/bf86a030a08b325a08e031ff71d0716a2f2f122b))
## v2.31.1 (2025-07-29)
### Bug Fixes
- **image_base**: Fix cleanup of uninitialized image layer
([`c1bdc50`](https://github.com/bec-project/bec_widgets/commit/c1bdc506e8099f178acdccbe0e1109deeeaaca38))
## v2.31.0 (2025-07-29)
### Bug Fixes
- **bec_main_window**: Main window have unified status bar on macOS
([`1d0490f`](https://github.com/bec-project/bec_widgets/commit/1d0490fff428d51f2cdb7d35a954a7cd62cbb65c))
- **color_button_native**: Removed BECWidget inheritance
([`e42ffd7`](https://github.com/bec-project/bec_widgets/commit/e42ffd7c015a026d8e0967ac6b5866cbbea7bfed))
- **decimal_spinbox**: Removed BECWidget inheritance
([`2bd6d00`](https://github.com/bec-project/bec_widgets/commit/2bd6d0089955172134afb4d39939890026ed43f0))
- **launch_window**: Logic for custom main window apps adjusted
([`e090ac4`](https://github.com/bec-project/bec_widgets/commit/e090ac49b72fa15ebf1c09164ff3c6de577cb939))
- **plugin_utils**: Plugins can be created from QWidgets, no need for BECWidget base class for
plugin creation
([`c2a918e`](https://github.com/bec-project/bec_widgets/commit/c2a918ef4b77ccd7fa43d1bc0b907d55a17a6c95))
- **scan_progressbar**: Added kwargs to init
([`7073e75`](https://github.com/bec-project/bec_widgets/commit/7073e75adf0eeb81f4f8e27eb99fc1b7a395c751))
- **utils**: Plugin template createWidget do not initialise widgets by default
([`728d4ef`](https://github.com/bec-project/bec_widgets/commit/728d4efd9646ffcecd7d1a2f70988a7d7c799124))
- **widgets**: Added missing __init__ files
([`6bbf512`](https://github.com/bec-project/bec_widgets/commit/6bbf5126cf586063ed08d6cd489d6a9af28eac35))
### Features
- **bec_main_window**: Plugin and rpc created
([`e4521d9`](https://github.com/bec-project/bec_widgets/commit/e4521d95286bbc598c3c05f357d247d950477b71))
### Refactoring
- **widgets**: All plugins regenerated
([`10cbb9a`](https://github.com/bec-project/bec_widgets/commit/10cbb9a05cb96a791448caff4ffc4115b76146d7))
### Testing
- **launch_window**: Mainwindow raise test removed, features is supported now
([`0854175`](https://github.com/bec-project/bec_widgets/commit/0854175acbda1d4de71358aec028539552a26448))
## v2.30.6 (2025-07-26)
### Bug Fixes
- **waveform**: Autorange is applied with 150ms delay after curve is added
([`61e5bde`](https://github.com/bec-project/bec_widgets/commit/61e5bde15f0e1ebe185ddbe81cd71ad581ae6009))
## v2.30.5 (2025-07-25)
### Bug Fixes
- **positioner-box**: Test to fix handling of none integer values for precision
([`b718b43`](https://github.com/bec-project/bec_widgets/commit/b718b438bacff6eb6cd6015f1a67dcf75c05dce4))
### Refactoring
- **positioner-box**: Cleanup, accept float precision
([`4d5df96`](https://github.com/bec-project/bec_widgets/commit/4d5df9608a9438b9f6d7508c323eb3772e53f37d))
## v2.30.4 (2025-07-25)
### Bug Fixes
- **cli**: Remove stderr from cli output when not using rpc
([`b4e0664`](https://github.com/bec-project/bec_widgets/commit/b4e0664011682cae9966aa2632210a6b60e11714))
## v2.30.3 (2025-07-23)
### Bug Fixes
- Cleanup subscriptions in device browser
([`0d81bdd`](https://github.com/bec-project/bec_widgets/commit/0d81bdd4ddb4ec474a414b107cbc7fc865253934))
## v2.30.2 (2025-07-23)
### Bug Fixes
- Factor out device name function and add test
([`8eb8225`](https://github.com/bec-project/bec_widgets/commit/8eb8225a7f56014d6093aa142b3a5d071837982e))
- **rpc_base**: Rpc_call wrapper passes full_name for Devices indeed of name
([`491d044`](https://github.com/bec-project/bec_widgets/commit/491d04467c8ce4e116d61e614895d1dcc6b4b201))
### Testing
- **test_plotting_framework_e2e**: Added test for waveform with passing device from dev container
([`3fd09fc`](https://github.com/bec-project/bec_widgets/commit/3fd09fceef2ffa7e7c3eee20176304bafb00d0db))
## v2.30.1 (2025-07-22)
### Bug Fixes
- Ignore KeyError in SignalLabel
([`608590c`](https://github.com/bec-project/bec_widgets/commit/608590c5421368d5bba0e4b0f5187d90cac323be))
## v2.30.0 (2025-07-22)
### Bug Fixes
- **device_browser**: Display signal for signals
([`3384ca0`](https://github.com/bec-project/bec_widgets/commit/3384ca02bdb5a2798ad3339ecf3e2ba7c121e28f))
- **device_signal_display**: Don't read omitted
([`b9af36a`](https://github.com/bec-project/bec_widgets/commit/b9af36a4f1c91e910d4fc738b17b90e92287a7e3))
- **signal_label**: Rewrite reading selection logic
([`cd17a4a`](https://github.com/bec-project/bec_widgets/commit/cd17a4aad905296eb0460ecc27e5920f5c2e8fe5))
- **signal_label**: Show all signals by default
([`22beadc`](https://github.com/bec-project/bec_widgets/commit/22beadcad061b328c986414f30fef57b64bad693))
- **signal_label**: Update signal from dialog correctly
([`959cedb`](https://github.com/bec-project/bec_widgets/commit/959cedbbd5a123eef5f3370287bf6476c48caab9))
- **signal_label**: Use read() instead of get() for init
([`f0dc992`](https://github.com/bec-project/bec_widgets/commit/f0dc99258607a5cc8af51686d01f7fd54ae2779f))
### Chores
- Update client.py
([`fd1f994`](https://github.com/bec-project/bec_widgets/commit/fd1f9941e046b7ae1e247dde39c20bcbc37ac189))
### Features
- **signal_label**: Property to display array data or not
([`ca4f975`](https://github.com/bec-project/bec_widgets/commit/ca4f97503bf06363e8e8a5d494a9857223da4104))
## v2.29.0 (2025-07-22)
### Features
- **notification_banner**: Notification centre for alarms implemented into BECMainWindow
([`cd9d22d`](https://github.com/bec-project/bec_widgets/commit/cd9d22d0b40d633af76cb1188b57feb7b6a5dbf2))
### Refactoring
- **notification_banner**: Becnotificationbroker done as singleton to sync all windows in the
session
([`7cda2ed`](https://github.com/bec-project/bec_widgets/commit/7cda2ed846d3c27799f4f15f6c5c667631b1ca55))
## v2.28.0 (2025-07-21)
### Features
- Disable editing while scan active
([`1397655`](https://github.com/bec-project/bec_widgets/commit/13976557fbdb71a1161029521d81a655d25dd134))
- Remove and readd device for config changes
([`8489ef4`](https://github.com/bec-project/bec_widgets/commit/8489ef4a69d69b39648b1a9270012f14f95c6121))
- Save and load config from devicebrowser
([`7f0098f`](https://github.com/bec-project/bec_widgets/commit/7f0098f1533d419cc75801c4d6cbea485c7bbf94))
## v2.27.1 (2025-07-17)
### Bug Fixes
- **image_roi_tree**: Rois signals are disconnected when roi tree widget is closed
([`00e3713`](https://github.com/bec-project/bec_widgets/commit/00e3713181916a432e4e9dec8a0d80205914cf77))
## v2.27.0 (2025-07-17)
### Features
- Add monaco editor
([`2373c7e`](https://github.com/bec-project/bec_widgets/commit/2373c7e996566a5b84c5a50e1c3e69de885713db))
## v2.26.0 (2025-07-17)
### Bug Fixes
- **config label**: Reset offset when toggling the label action
([`b8ae7b2`](https://github.com/bec-project/bec_widgets/commit/b8ae7b2e96071b6dc59dae7ffa72bbedc6aaea23))
- **performance_bundle**: Fix performance bundle cleanup
([`23674cc`](https://github.com/bec-project/bec_widgets/commit/23674ccf592a2caa0b57ae64ad1499c270b7d469))
### Features
- **device combobox**: Add option to insert an empty element
([`debd347`](https://github.com/bec-project/bec_widgets/commit/debd347b64a3d2ca07ddcd5ef3a3394d1ffb67e3))
- **heatmap**: Add interpolation and oversampling UI components
([`1d8069e`](https://github.com/bec-project/bec_widgets/commit/1d8069e391412e3096a3c1e7181398dd4e609650))
### Refactoring
- **image_base**: Cleanup
([`46a9178`](https://github.com/bec-project/bec_widgets/commit/46a91784d237137128965ad585e38085e931e5d4))
### Testing
- **history**: Add history message helper methods to conftest
([`44cc061`](https://github.com/bec-project/bec_widgets/commit/44cc06137ccfbc087bdd3005156ff28effe05f23))
## v2.25.0 (2025-07-17)
### Bug Fixes
- **bec-progressbar**: Add flag for theme update
([`694a6c4`](https://github.com/bec-project/bec_widgets/commit/694a6c49608b68e25dc0c76b58855b96f3f0ef0b))
### Continuous Integration
- **bec**: Add child_repos test for bec (unit and e2e tests)
([`a9c8995`](https://github.com/bec-project/bec_widgets/commit/a9c8995ac0b39f6bc327887f43f7d4d6e6e89db2))
- **plugin**: Add plugin repository test to BW ci
([`2b06e34`](https://github.com/bec-project/bec_widgets/commit/2b06e34ecff8c0a92a2b235f375e837729736b2a))
### Features
- **scan-history-browser**: Add history browser and history metadata viewer
([`9caae4c`](https://github.com/bec-project/bec_widgets/commit/9caae4cf40d3876175b827abb735ae227ae0bcea))
### Refactoring
- Add additional components for history metadata, device view and popup ui
([`cf97cc1`](https://github.com/bec-project/bec_widgets/commit/cf97cc1805e16073c7849d1f9375e2ebd2176b70))
- Cleanup, add compact popup view for scan_history_browser and update tests
([`25b2737`](https://github.com/bec-project/bec_widgets/commit/25b2737aacfaa45f255afb6ebf467d5781165a8e))
## v2.24.1 (2025-07-15)
### Bug Fixes
- Update signal label for device_edit changes
([`bde5238`](https://github.com/bec-project/bec_widgets/commit/bde523806fdb6ab224b485f65b615f89dfe20b7b))
## v2.24.0 (2025-07-15)
### Bug Fixes
- Hide validity LED, show message as tooltip
([`530797a`](https://github.com/bec-project/bec_widgets/commit/530797a5568957dde9f47f417310f5c4d2493906))
- Validate some config data
([`c660e51`](https://github.com/bec-project/bec_widgets/commit/c660e5141f191a782c224ee1b83536793639eecb))
- **device_browser**: Un-nest exception
([`8b2d605`](https://github.com/bec-project/bec_widgets/commit/8b2d6052e808f8b4063e5f45c40e4460524f044e))
### Documentation
- Docstring for config dialog
([`1925e6a`](https://github.com/bec-project/bec_widgets/commit/1925e6ac7f98875eb5980637ae3293e22b459e28))
### Features
- (#495) add devices through browser
([`170be0c`](https://github.com/bec-project/bec_widgets/commit/170be0c7d3bb1f6e5f2305958909ef68cd987fbd))
- **#495**: Add validation against existing device names
([`900153b`](https://github.com/bec-project/bec_widgets/commit/900153bc0b8cec7bad82e23b3772c66e84900a17))
- **device_browser**: Connect update to item refresh
([`130cc24`](https://github.com/bec-project/bec_widgets/commit/130cc24b351684358558ab81c0111f10f9abb11f))
- **device_browser**: Device deletion from config
([`8dc7265`](https://github.com/bec-project/bec_widgets/commit/8dc72656ef46ae7be886f9da59beb768f5381b4f))
## v2.23.0 (2025-07-11)
### Features
- **widget_finder**: Widget to fetch any other widget by class from currently running app
([`a9fce17`](https://github.com/bec-project/bec_widgets/commit/a9fce175b720ad85a5cefcab99d79fbcb971ff4a))
- **widget_io**: Utility function to find widget in the app by class
([`783d042`](https://github.com/bec-project/bec_widgets/commit/783d042e8c469774fc8407921462a99c96f6d408))
## v2.22.2 (2025-07-11)
### Bug Fixes
- **plot_base**: Autorange takes into account only visible curves
([`7643986`](https://github.com/bec-project/bec_widgets/commit/76439866c1fd09cb7d9d48dfccdc7b1943bfbc0f))
## v2.22.1 (2025-07-11)
### Bug Fixes
- **crosshair**: Crosshair mouse_moved can be set manually
([`63a8da6`](https://github.com/bec-project/bec_widgets/commit/63a8da680d263a50102aacf463ec6f6252562f9d))
- **heatmap**: Fix pixel size calculation for arbitrary shapes
([`6c49425`](https://github.com/bec-project/bec_widgets/commit/6c494258f82059a2472f43bb8287390ce1aba704))
## v2.22.0 (2025-07-10)
### Bug Fixes
- **crosshair**: Add slot to reset mouse markers
([`adaad4f`](https://github.com/bec-project/bec_widgets/commit/adaad4f4d5ebf775a337e23a944ba9eb289d01a0))
- **crosshair**: Fix crosshair support for transformations
([`3ba0fc4`](https://github.com/bec-project/bec_widgets/commit/3ba0fc4b442e5926f27a13f09d628c30987f2cf8))
- **image**: Reset crosshair on new scan
([`e594de3`](https://github.com/bec-project/bec_widgets/commit/e594de3ca39970f91f5842693eeb1fac393eaa34))
- **image item**: Fix processor for nans in images
([`39c316d`](https://github.com/bec-project/bec_widgets/commit/39c316d6eadfdfbd483661b67720a7e224a46712))
- **image_base**: Move cbar init to image base
([`8929778`](https://github.com/bec-project/bec_widgets/commit/8929778f073c40a9eabba7eda2415fc9af1072bb))
- **image_processor**: Support for nans in nd arrays
([`a6fc799`](https://github.com/bec-project/bec_widgets/commit/a6fc7993a3d22cfd086310c8e6dad3f9f3d1e9fe))
### Features
- Add heatmap widget
([`0c76b0c`](https://github.com/bec-project/bec_widgets/commit/0c76b0c49598d1456aab266b483de327788028fd))
- **image_item**: Add support for qtransform
([`324a5bd`](https://github.com/bec-project/bec_widgets/commit/324a5bd3d9ed278495c6ba62453b02061900ae32))
## v2.21.4 (2025-07-08)
### Bug Fixes
- **image_roi_tree**: Changing color dialog from ColorButtonNative is open once
([`244bca4`](https://github.com/bec-project/bec_widgets/commit/244bca4e1ec7c00109534b9f503ff2eb125c1ffe))
## v2.21.3 (2025-07-03) ## v2.21.3 (2025-07-03)
### Bug Fixes ### Bug Fixes
+166 -47
View File
@@ -1,81 +1,200 @@
# BEC Widgets ![banner_opti](https://github.com/user-attachments/assets/44e483be-3f0d-4eb0-bd98-613157456b81)
# 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) [![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/) [![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) [![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) [![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) [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-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/) [![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) [![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) [![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
plugin system.
**⚠️ Important Notice:** ## Highlights
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨 - **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
a single line of code**. Just launch, drag widgets, and do your experiment.
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: dragdock, tab,
split, and export profiles/workspaces for reuse.
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
`BECIPythonClient`.
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
with the custom BEC loader for a zeroglue workflow.
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
acquisition history.
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
or override behavior as needed.
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec). ## Table of Contents
- [Installation](#installation)
- [Features](#features)
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
- [Widget Library](#widget-library)
- [Documentation](#documentation)
- [License](#license)
## Installation ## Installation
Use any of the following setups:
### Stable release
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets: Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash ```bash
pip install bec_widgets[pyside6] pip install bec_widgets
``` ```
### From source (recommended for development)
For development purposes, you can clone the repository and install the package locally in editable mode: For development purposes, you can clone the repository and install the package locally in editable mode:
```bash ```bash
git clone https://gitlab.psi.ch/bec/bec-widgets git clone https://github.com/bec-project/bec_widgets.git
cd bec_widgets cd bec_widgets
pip install -e .[dev,pyside6] pip install -e .[dev]
``` ```
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is ## Features
specified.
### 1. Dock area interface: build GUIs in seconds
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
operate immediately and refine later with RPC or Designer if needed.
![dock_area_example](https://github.com/user-attachments/assets/219a2806-19a8-4a07-9734-b7b554850833)
### 2. Qt Designer plugins + BEC Launcher (no glue)
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
visually in Designer, save a `.ui`, then launch it with
the **BEC Launcher**—no glue code. Widgets autoconnect to BEC/Redis on startup, so your UI is operational immediately.
![designer_opti](https://github.com/user-attachments/assets/fed4843c-1cce-438a-b41f-6636fa5e1545)
### 3. Robust RPC from CLI & remote scripting
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
active devices, topics)—so your UI can be heavily automated.
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
actions,switch profiles, open diagnostic views, or start specific scans.
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
![rpc_opti](https://github.com/user-attachments/assets/666be7fb-9a0d-44c2-8d44-2f9d1dae4497)
### 4. Rapid development (extensible by design)
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
Designer plugin that are immediately usable with your BEC setup. Widgets
come online with live BEC/Redis wiring out of the box.
<details>
<summary> View code: Example Widget </summary>
```python
from typing import Literal
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
from qtpy.QtCore import Slot
from bec_lib.endpoints import MessageEndpoints
from bec_widgets import BECWidget, SafeSlot
class SimpleMotorWidget(BECWidget, QWidget):
USER_ACCESS = ["move"]
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
super().__init__(parent=parent, **kwargs)
self.motor_name = motor_name
self.step = float(step)
self.get_bec_shortcuts()
self.value_label = QLabel(f"{self.motor_name}: —")
self.btn_left = QPushButton("◀︎ -5")
self.btn_right = QPushButton("+5 ▶︎")
row = QHBoxLayout()
row.addWidget(self.btn_left)
row.addWidget(self.btn_right)
col = QVBoxLayout(self)
col.addWidget(self.value_label)
col.addLayout(row)
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
@SafeSlot(dict, dict)
def on_readback(self, data: dict, meta: dict):
current_value = data.get("signals").get(self.motor_name).get('value')
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
@Slot(str, float)
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
if direction == "left":
self.dev[self.motor_name].move(-step, relative=True)
else:
self.dev[self.motor_name].move(step, relative=True)
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
w = SimpleMotorWidget(motor_name="samx", step=5.0)
w.setWindowTitle("MotorJogWidget")
w.resize(280, 90)
w.show()
sys.exit(app.exec_())
```
</details>
## Widget Library
A large and growing catalog—plug, configure, run:
### Plotting
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
history data.
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
### Scan orchestration and motion control.
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
Positioner boxes and tweak controls handle precise moves, homing, and calibration for daytoday alignment.
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
## Documentation ## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/). Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## Contributing
All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
> ```
> <type>(<scope>): <short summary>
> │ │ │
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
> │ │
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
> │ elements|forms|http|language-service|localize|platform-browser|
> │ platform-browser-dynamic|platform-server|router|service-worker|
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
> │ devtools
>
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ```
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
> Must be one of the following:
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
> * **feat**: A new feature
> * **fix**: A bug fix
> * **perf**: A code change that improves performance
> * **refactor**: A code change that neither fixes a bug nor adds a feature
> * **test**: Adding missing tests or correcting existing tests
## License ## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/) [BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
+28
View File
@@ -0,0 +1,28 @@
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
Core Dependencies:
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
Additional Dependencies (Testing/Development):
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
+21 -14
View File
@@ -31,7 +31,7 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates 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.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -395,20 +395,24 @@ class LaunchWindow(BECMainWindow):
if isinstance(result_widget, BECMainWindow): if isinstance(result_widget, BECMainWindow):
result_widget.show() result_widget.show()
else: else:
window = BECMainWindow() window = BECMainWindowNoRPC()
window.setCentralWidget(result_widget) window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}") window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show() window.show()
return result_widget return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow: def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
# Load the custom UI file """
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
instantiate it directly; otherwise, embed it in a UILaunchWindow.
"""
if ui_file is None: if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.") raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0] filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename) WidgetContainerUtils.raise_for_invalid_name(filename)
# Parse the UI to detect top-level widget class
tree = ET.parse(ui_file) tree = ET.parse(ui_file)
root = tree.getroot() root = tree.getroot()
# Check if the top-level widget is a QMainWindow # Check if the top-level widget is a QMainWindow
@@ -416,19 +420,22 @@ class LaunchWindow(BECMainWindow):
if widget is None: if widget is None:
raise ValueError("No widget found in the UI file.") raise ValueError("No widget found in the UI file.")
if widget.attrib.get("class") == "QMainWindow": # Load the UI into a widget
raise ValueError( loader = UILoader(None)
"Loading a QMainWindow from a UI file is currently not supported. " loaded = loader.loader(ui_file)
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
) # Display the UI in a BECMainWindow
if isinstance(loaded, BECMainWindow):
window = loaded
window.object_name = filename
else:
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents() QApplication.processEvents()
result_widget = UILoader(window).loader(ui_file) window.setWindowTitle(f"BEC - {filename}")
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show() window.show()
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}") logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
return window return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates: def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
@@ -451,7 +458,7 @@ class LaunchWindow(BECMainWindow):
WidgetContainerUtils.raise_for_invalid_name(name) WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow() window = BECMainWindowNoRPC()
widget_instance = widget(root_widget=True, object_name=name) widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget) assert isinstance(widget_instance, QWidget)
+1215 -30
View File
File diff suppressed because it is too large Load Diff
+56 -16
View File
@@ -14,18 +14,21 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console from rich.console import Console
from rich.table import Table 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.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
else: else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage") GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
logger = bec_logger.logger logger = bec_logger.logger
@@ -51,7 +54,7 @@ def _filter_output(output: str) -> str:
def _get_output(process, logger) -> None: def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.error} log_func = {process.stdout: logger.debug, process.stderr: logger.info}
stream_buffer = {process.stdout: [], process.stderr: []} stream_buffer = {process.stdout: [], process.stderr: []}
try: try:
os.set_blocking(process.stdout.fileno(), False) os.set_blocking(process.stdout.fileno(), False)
@@ -151,8 +154,10 @@ def wait_for_server(client: BECGuiClient):
raise RuntimeError("GUI is not alive") raise RuntimeError("GUI is not alive")
try: try:
if client._gui_started_event.wait(timeout=timeout): if client._gui_started_event.wait(timeout=timeout):
client._gui_started_timer.cancel() if client._gui_started_timer is not None:
client._gui_started_timer.join() # cancel the timer, we are done
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else: else:
raise TimeoutError("Could not connect to GUI server") raise TimeoutError("Could not connect to GUI server")
finally: finally:
@@ -261,18 +266,37 @@ class BECGuiClient(RPCBase):
def start(self, wait: bool = False) -> None: def start(self, wait: bool = False) -> None:
"""Start the GUI server.""" """Start the GUI server."""
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
return self._start(wait=wait) return self._start(wait=wait)
def show(self): def show(self, wait=True) -> None:
"""Show the GUI window.""" """
Show the GUI window.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive(): if self._check_if_server_is_alive():
return self._show_all() return self._show_all()
return self.start(wait=True) return self._start(wait=wait)
def hide(self): def hide(self):
"""Hide the GUI window.""" """Hide the GUI window."""
return self._hide_all() return self._hide_all()
def raise_window(self, wait: bool = True) -> None:
"""
Bring GUI windows to the front.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive():
return self._raise_all()
return self._start(wait=wait)
def new( def new(
self, self,
name: str | None = None, name: str | None = None,
@@ -382,6 +406,9 @@ class BECGuiClient(RPCBase):
""" """
Start the GUI server, and execute callback when it is launched Start the GUI server, and execute callback when it is launched
""" """
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None: if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...") logger.success("GUI starting...")
self._startup_timeout = 5 self._startup_timeout = 5
@@ -428,8 +455,8 @@ class BECGuiClient(RPCBase):
self._update_dynamic_namespace(self._server_registry) self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self): def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self) if self.launcher and len(self._top_level) == 0:
rpc_client._run_rpc("show") # pylint: disable=protected-access self.launcher._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values(): for window in self._top_level.values():
window.show() window.show()
@@ -439,11 +466,24 @@ class BECGuiClient(RPCBase):
def _hide_all(self): def _hide_all(self):
with wait_for_server(self): with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self) if self._killed:
rpc_client._run_rpc("hide") # pylint: disable=protected-access return
if not self._killed: self.launcher._run_rpc("hide")
for window in self._top_level.values(): for window in self._top_level.values():
window.hide() window.hide()
def _do_raise_all(self):
"""Bring GUI windows to the front."""
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("raise") # pylint: disable=protected-access
for window in self._top_level.values():
window._run_rpc("raise") # type: ignore[attr-defined]
def _raise_all(self):
with wait_for_server(self):
if self._killed:
return
return self._do_raise_all()
def _update_dynamic_namespace(self, server_registry: dict): def _update_dynamic_namespace(self, server_registry: dict):
""" """
@@ -524,7 +564,7 @@ if __name__ == "__main__": # pragma: no cover
# Test the client_utils.py module # Test the client_utils.py module
gui = BECGuiClient() gui = BECGuiClient()
gui.start(wait=True) gui.show(wait=True)
gui.new().new(widget="Waveform") gui.new().new(widget="Waveform")
time.sleep(10) time.sleep(10)
finally: finally:
+19 -4
View File
@@ -53,7 +53,7 @@ from __future__ import annotations
{base_imports} {base_imports}
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""} {"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger logger = bec_logger.logger
@@ -180,7 +180,10 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. " f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list." f"Please check the USER_ACCESS list."
) )
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)): if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties # for the cli, we can map qt properties to regular properties
if is_property_setter: if is_property_setter:
@@ -205,14 +208,26 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ... def {method}{str(sig_overload)}: ...
""" """
self.content += """ self.content += f"""
@rpc_call""" {self._rpc_call(timeout)}"""
self.content += f""" self.content += f"""
def {method}{str(sig)}: def {method}{str(sig)}:
\"\"\" \"\"\"
{doc} {doc}
\"\"\"""" \"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str): def write(self, file_name: str):
""" """
Write the content to a file, automatically formatted with black. Write the content to a file, automatically formatted with black.
+50 -9
View File
@@ -7,6 +7,7 @@ from functools import wraps
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient from bec_lib.client import BECClient
from bec_lib.device import DeviceBaseWithConfig
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from from bec_lib.utils.import_utils import lazy_import, lazy_import_from
@@ -24,6 +25,43 @@ else:
# pylint: disable=protected-access # pylint: disable=protected-access
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
# if dev.<device> is passed to GUI, it passes full_name
if hasattr(arg, "full_name"):
return arg.full_name
elif hasattr(arg, "name"):
return arg.name
return arg
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func): def rpc_call(func):
""" """
A decorator for calling a function on the server. A decorator for calling a function on the server.
@@ -47,15 +85,7 @@ def rpc_call(func):
return None # func(*args, **kwargs) return None # func(*args, **kwargs)
caller_frame = caller_frame.f_back caller_frame = caller_frame.f_back
out = [] args, kwargs = _transform_args_kwargs(args, kwargs)
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive(): if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive") raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs) return self._run_rpc(func.__name__, *args, **kwargs)
@@ -172,6 +202,11 @@ class RPCBase:
parent = parent._parent parent = parent._parent
return parent # type: ignore return parent # type: ignore
def raise_window(self):
"""Bring this widget (or its container) to the front."""
# Use explicit call to ensure action name is 'raise' (not 'raise_')
return self._run_rpc("raise")
def _run_rpc( def _run_rpc(
self, self,
method, method,
@@ -195,6 +230,12 @@ class RPCBase:
Returns: Returns:
The result of the RPC call. The result of the RPC call.
""" """
if method in ["show", "hide", "raise"] and gui_id is None:
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
gui_id = obj.get("container_proxy") # type: ignore
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage( rpc_msg = messages.GUIInstructionMessage(
action=method, action=method,
+3
View File
@@ -9,6 +9,7 @@ from contextlib import redirect_stderr, redirect_stdout
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig from bec_lib.service_config import ServiceConfig
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
@@ -142,6 +143,8 @@ class GUIServer:
""" """
Shutdown the GUI server. Shutdown the GUI server.
""" """
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher: if self.dispatcher:
self.dispatcher.stop_cli_server() self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all() self.dispatcher.disconnect_all()
@@ -55,7 +55,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# "btn6": self.btn6, # "btn6": self.btn6,
# "pb": self.pb, # "pb": self.pb,
# "pi": self.pi, # "pi": self.pi,
# "wf": self.wf, "wf": self.wf,
# "scatter": self.scatter, # "scatter": self.scatter,
# "scatter_mi": self.scatter, # "scatter_mi": self.scatter,
# "mwf": self.mwf, # "mwf": self.mwf,
@@ -105,12 +105,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# self.btn5 = QPushButton("Button 5") # self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6") # self.btn6 = QPushButton("Button 6")
# #
# fifth_tab = QWidget() fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab) fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform() self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf) fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen") tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
# #
sixth_tab = QWidget() sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab) sixth_tab_layout = QVBoxLayout(sixth_tab)
+1 -1
View File
@@ -173,7 +173,7 @@ class FakePositioner(BECPositioner):
def set_read_value(self, value): def set_read_value(self, value):
self.read_value = value self.read_value = value
def read(self): def read(self, cached=False):
return self.signals return self.signals
def set_limits(self, limits): def set_limits(self, limits):
+1 -21
View File
@@ -161,8 +161,6 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent # 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent) self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups # Error popups
self.error_utility = ErrorPopupUtility() self.error_utility = ErrorPopupUtility()
@@ -186,24 +184,6 @@ class BECConnector:
except: except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}") logger.error(f"Error getting parent_id for {self.__class__.__name__}")
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: def change_object_name(self, name: str) -> None:
""" """
Change the object name of the widget. Unregister old name and register the new one. Change the object name of the widget. Unregister old name and register the new one.
@@ -233,7 +213,7 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent. - 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. - If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
""" """
QApplication.processEvents() QApplication.sendPostedEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self) parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec: if parent_bec:
+7 -4
View File
@@ -38,9 +38,11 @@ def _loaded_submodules_from_specs(
try: try:
submodule.__loader__.exec_module(submodule) submodule.__loader__.exec_module(submodule)
except Exception as e: except Exception as e:
logger.error( exception_text = "".join(traceback.format_exception(e))
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}" if "(most likely due to a circular import)" in exception_text:
) logger.warning(f"Circular import encountered while loading {submodule}")
else:
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
yield submodule yield submodule
@@ -59,7 +61,8 @@ def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
module, module,
predicate=lambda item: inspect.isclass(item) predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget) and issubclass(item, BECWidget)
and item is not BECWidget, and item is not BECWidget
and not item.__module__.startswith("bec_widgets"),
) )
return BECClassContainer( return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v) BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
@@ -0,0 +1,86 @@
import traceback
from pathlib import Path
from typing import Annotated
import copier
import typer
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_repo_path
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
logger = bec_logger.logger
_app = typer.Typer(rich_markup_mode="rich")
def _commit_added_widget(repo: Path, name: str):
git_stage_files(repo, [".copier-answers.yml"])
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
make_commit(repo, f"plugin-manager added new widget: {name}")
logger.info(f"Committing new widget {name}")
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
return name in [w["name"] for w in widget_list]
def _editor_cb(ctx: typer.Context, value: bool):
if value and not ctx.params["use_ui"]:
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
return value
_bold_blue = "\033[34m\033[1m"
_off = "\033[0m"
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
Open the created widget .ui file in bec-designer now?"""
@_app.command()
def widget(
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
open_editor: Annotated[
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
] = True,
):
"""Create a new widget plugin with the given name.
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
bec-designer and the compiled python version will be updated when changes are made and saved."""
if (formatted_name := name.lower().replace("-", "_")) != name:
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
if not formatted_name.isidentifier():
logger.error(
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
)
exit(-1)
logger.info(f"Adding new widget {formatted_name} to the template...")
try:
repo = Path(plugin_repo_path())
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
logger.error(f"Widget {formatted_name} already exists!")
exit(-1)
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
copier.run_update(
repo,
data=plugin_data,
defaults=True,
unsafe=True,
overwrite=True,
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
)
_commit_added_widget(repo, formatted_name)
except Exception:
logger.error(traceback.format_exc())
logger.error("exiting...")
exit(-1)
logger.success(f"Added widget {formatted_name}!")
if open_editor:
open_and_watch_ui_editor(formatted_name)
@@ -0,0 +1,136 @@
import re
import subprocess
from pathlib import Path
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from watchdog.events import (
DirCreatedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEvent,
FileSystemEventHandler,
)
from watchdog.observers import Observer
from bec_widgets.utils.bec_designer import open_designer
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
class RecompileHandler(FileSystemEventHandler):
def __init__(self, in_file: Path, out_file: Path) -> None:
super().__init__()
self.in_file = str(in_file)
self.out_file = str(out_file)
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
self._widget_import_re = re.compile(
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
)
self._widget_modules = {
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
}
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
self.recompile(event)
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
self.recompile(event)
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
self.recompile(event)
def recompile(self, event: FileSystemEvent) -> None:
if event.src_path == self.in_file or event.dest_path == self.in_file:
self._recompile()
def _recompile(self):
logger.success(".ui file modified, recompiling...")
code = subprocess.call(
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
)
logger.success(f"compilation exited with code {code}")
if code != 0:
return
self._add_comment_to_file()
logger.success("updating imports...")
self._update_imports()
logger.success("formatting...")
code = subprocess.call(
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
)
if code != 0:
logger.error(f"Error while running black on {self.out_file}, code: {code}")
return
code = subprocess.call(
[
"isort",
"--line-length=100",
"--profile=black",
"--multi-line=3",
"--trailing-comma",
self.out_file,
]
)
if code != 0:
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
return
logger.success("done!")
def _add_comment_to_file(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
f.write(
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
)
f.write(initial)
def _update_imports(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
qtpy_imports = re.sub(
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
)
print(self._widget_modules)
print(re.findall(self._widget_import_re, qtpy_imports))
widget_imports = re.sub(
self._widget_import_re,
lambda ob: (
f"from {module} import {ob.group(2)}"
if (module := self._widget_modules.get(ob.group(2))) is not None
else ob.group(1)
),
qtpy_imports,
)
f.write(widget_imports)
f.truncate()
def open_and_watch_ui_editor(widget_name: str):
logger.info(f"Opening the editor for {widget_name}, and watching")
repo = Path(plugin_repo_path())
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
ui_file = widget_dir / f"{widget_name}.ui"
ui_outfile = widget_dir / f"{widget_name}_ui.py"
logger.info(
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
)
recompile_handler = RecompileHandler(ui_file, ui_outfile)
observer = Observer()
observer.schedule(recompile_handler, str(ui_file.parent))
observer.start()
try:
open_designer([str(ui_file)])
finally:
observer.stop()
observer.join()
logger.info("Editing session ended, exiting...")
+38 -3
View File
@@ -1,15 +1,19 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import darkdetect import darkdetect
import shiboken6
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, Slot from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock from bec_widgets.widgets.containers.dock import BECDock
@@ -87,7 +91,7 @@ class BECWidget(BECConnector):
theme = "dark" theme = "dark"
self.apply_theme(theme) self.apply_theme(theme)
@Slot(str) @SafeSlot(str)
def apply_theme(self, theme: str): def apply_theme(self, theme: str):
""" """
Apply the theme to the widget. Apply the theme to the widget.
@@ -96,12 +100,43 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied. theme(str, optional): The theme to be applied.
""" """
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def cleanup(self): def cleanup(self):
"""Cleanup the widget.""" """Cleanup the widget."""
with RPCRegister.delayed_broadcast(): with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method # All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}") logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self) self.rpc_register.remove_rpc(self)
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
def closeEvent(self, event): def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up.""" """Wrap the close even to ensure the rpc_register is cleaned up."""
-9
View File
@@ -259,12 +259,3 @@ class CompactPopupWidget(QWidget):
@expand_popup.setter @expand_popup.setter
def expand_popup(self, popup: bool): def expand_popup(self, popup: bool):
self._expand_popup = popup self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)
+167 -64
View File
@@ -5,9 +5,13 @@ from typing import Any
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal, Slot from qtpy.QtCore import QObject, QPointF, Qt, Signal
from qtpy.QtGui import QCursor, QTransform
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.image.image_item import ImageItem
class CrosshairScatterItem(pg.ScatterPlotItem): class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None): def setDownsampling(self, ds=None, auto=None, method=None):
@@ -160,7 +164,7 @@ class Crosshair(QObject):
qapp.theme_signal.theme_updated.connect(self._update_theme) qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme() self._update_theme()
@Slot(str) @SafeSlot(str)
def _update_theme(self, theme: str | None = None): def _update_theme(self, theme: str | None = None):
"""Update the theme.""" """Update the theme."""
if theme is None: if theme is None:
@@ -187,7 +191,7 @@ class Crosshair(QObject):
self.coord_label.fill = pg.mkBrush(label_bg_color) self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None) self.coord_label.border = pg.mkPen(None)
@Slot(int) @SafeSlot(int)
def update_highlighted_curve(self, curve_index: int): def update_highlighted_curve(self, curve_index: int):
""" """
Update the highlighted curve in the case of multiple curves in a plot item. Update the highlighted curve in the case of multiple curves in a plot item.
@@ -205,8 +209,11 @@ class Crosshair(QObject):
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"): if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only # Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]] self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
else: elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
# Handle all curves # Handle visible items in the plot item
self.items = self.plot_item.visible_items()
else: # Non PlotBase case
# Handle all items
self.items = self.plot_item.items self.items = self.plot_item.items
# Create or update markers # Create or update markers
@@ -265,15 +272,47 @@ class Crosshair(QObject):
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False [0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
) )
self.marker_2d_row.skip_auto_range = True self.marker_2d_row.skip_auto_range = True
if item.image_transform is not None:
self.marker_2d_row.setTransform(item.image_transform)
self.plot_item.addItem(self.marker_2d_row) self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting # Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI( self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False [0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
) )
if item.image_transform is not None:
self.marker_2d_col.setTransform(item.image_transform)
self.marker_2d_col.skip_auto_range = True self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col) self.plot_item.addItem(self.marker_2d_col)
@SafeSlot()
def update_markers_on_image_change(self):
"""
Update markers when the image changes, e.g. when the
image shape or transformation changes.
"""
for item in self.items:
if not isinstance(item, pg.ImageItem):
continue
if self.marker_2d_row is not None:
self.marker_2d_row.setSize([item.image.shape[0], 1])
self.marker_2d_row.setTransform(item.image_transform)
if self.marker_2d_col is not None:
self.marker_2d_col.setSize([1, item.image.shape[1]])
self.marker_2d_col.setTransform(item.image_transform)
# Get the current mouse position
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
def snap_to_data( def snap_to_data(
self, x: float, y: float self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]: ) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
@@ -316,9 +355,25 @@ class Crosshair(QObject):
image_2d = item.image image_2d = item.image
if image_2d is None: if image_2d is None:
continue continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors # Map scene coordinates (plot units) back to image pixel coordinates
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1)) if item.image_transform is not None:
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1)) inv_transform, _ = item.image_transform.inverted()
xy_trans = inv_transform.map(QPointF(x, y))
else:
xy_trans = QPointF(x, y)
# Define valid pixel coordinate bounds
min_x_px, min_y_px = 0, 0
max_x_px = image_2d.shape[0] - 1 # columns
max_y_px = image_2d.shape[1] - 1 # rows
# Clip the mapped coordinates to the image bounds
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
# Store snapped pixel positions
x_values[name] = px
y_values[name] = py
if x_values and y_values: if x_values and y_values:
if all(v is None for v in x_values.values()) or all( if all(v is None for v in x_values.values()) or all(
@@ -358,60 +413,74 @@ class Crosshair(QObject):
return list_x[original_index], list_y[original_index] return list_x[original_index], list_y[original_index]
def mouse_moved(self, event): @SafeSlot(object, tuple)
"""Handles the mouse moved event, updating the crosshair position and emitting signals. def mouse_moved(self, event=None, manual_pos=None):
"""
Handles the mouse moved event, updating the crosshair position and emitting signals.
Args: Args:
event: The mouse moved event event(object): The mouse moved event, which contains the scene position.
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
""" """
pos = event[0] # Determine target (x, y) in *plot* coordinates
if manual_pos is not None:
x, y = manual_pos
else:
if event is None:
return # nothing to do
scene_pos = event[0] # SignalProxy bundle
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
return
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
x, y = view_pos.x(), view_pos.y()
# Update crosshair visuals
self.v_line.setPos(x)
self.h_line.setPos(y)
self.update_markers() self.update_markers()
if self.plot_item.vb.sceneBoundingRect().contains(pos): scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
mouse_point = self.plot_item.vb.mapSceneToView(pos) self.crosshairChanged.emit((scaled_x, scaled_y))
x, y = mouse_point.x(), mouse_point.y() self.positionChanged.emit((x, y))
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
x_snap_values, y_snap_values = self.snap_to_data(x, y) snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None: if snap_x_vals is None or snap_y_vals is None:
return return
if all(v is None for v in x_snap_values.values()) or all( if all(v is None for v in snap_x_vals.values()) or all(
v is None for v in y_snap_values.values() v is None for v in snap_y_vals.values()
): ):
# not sure how we got here, but just to be safe... return
return
precision = self._current_precision() precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem): for item in self.items:
name = item.name() or str(id(item)) if isinstance(item, pg.PlotDataItem):
x, y = x_snap_values[name], y_snap_values[name] name = item.name() or str(id(item))
if x is None or y is None: sx, sy = snap_x_vals[name], snap_y_vals[name]
continue if sx is None or sy is None:
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# 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:
continue continue
self.marker_moved_1d[name].setData([sx], [sy])
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
self.coordinatesChanged1D.emit(
(name, round(sx_s, precision), round(sy_s, precision))
)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
px, py = snap_x_vals[name], snap_y_vals[name]
if px is None or py is None:
continue
# Respect image transforms
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(px, py, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, py])
self.marker_2d_col.setPos([px, 0])
self.coordinatesChanged2D.emit((name, px, py))
def mouse_clicked(self, event): def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals. """Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -462,15 +531,35 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y]) if isinstance(item, ImageItem) and item.image_transform is not None:
# Set position of vertical ROI (column) row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_col.setPos([x, 0]) self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y) coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit) self.coordinatesClicked2D.emit(coordinate_to_emit)
else: else:
continue continue
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
"""
Maps the given x and y coordinates to the transformed position using the provided transform.
Args:
x (float): The x-coordinate to transform.
y (float): The y-coordinate to transform.
transform (QTransform): The transformation to apply.
"""
origin = transform.map(QPointF(0, 0))
row = transform.map(QPointF(0, y)) - origin
col = transform.map(QPointF(x, 0)) - origin
return row, col
def clear_markers(self): def clear_markers(self):
"""Clears the markers from the plot.""" """Clears the markers from the plot."""
for marker in self.marker_moved_1d.values(): for marker in self.marker_moved_1d.values():
@@ -512,8 +601,18 @@ class Crosshair(QObject):
image = item.image image = item.image
if image is None: if image is None:
continue continue
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1)) if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
pt = inv_transform.map(QPointF(x, y))
px, py = pt.x(), pt.y()
else:
px, py = x, y
# Clip to valid pixel indices
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
intensity = image[ix, iy] intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}" text += f"\nIntensity: {intensity:.{precision}f}"
break break
@@ -533,15 +632,19 @@ class Crosshair(QObject):
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked() self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers() self.clear_markers()
def cleanup(self): @SafeSlot()
def reset(self):
"""Resets the crosshair to its initial state."""
if self.marker_2d_row is not None: if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row) self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None self.marker_2d_row = None
if self.marker_2d_col is not None: if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col) self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None self.marker_2d_col = None
self.clear_markers()
def cleanup(self):
self.reset()
self.plot_item.removeItem(self.v_line) self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line) self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label) self.plot_item.removeItem(self.coord_label)
self.clear_markers()
+4
View File
@@ -28,6 +28,10 @@ class EntryValidator:
if not available_entries: if not available_entries:
available_entries = [name] available_entries = [name]
# edge case for if name is passed instead of full_name, should not happen
if entry in signals_dict:
entry = signals_dict[entry].get("obj_name", entry)
if entry is None or entry == "": if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in available_entries: if entry not in available_entries:
+23 -21
View File
@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
self._form_grid_container = QWidget(parent=self) self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid_container.setLayout(QVBoxLayout())
self._layout.addWidget(self._form_grid_container)
self._form_grid = QWidget(parent=self._form_grid_container) self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout()) self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None self._widget_types: dict | None = None
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
def _add_griditem(self, item: FormItemSpec, row: int): def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout() grid = self._form_grid.layout()
label = QLabel(item.name) label = QLabel(parent=self._form_grid, text=item.name)
label.setProperty("_model_field_name", item.name) label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name) label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0) grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item) widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
widget.valueChanged.connect(self.value_changed) widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1) grid.addWidget(widget, row, 1)
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
} }
def _clear_grid(self): def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None: gl = self._form_grid.layout()
while old_layout.count(): while w := gl.takeAt(0):
item = old_layout.takeAt(0) w = w.widget()
widget = item.widget() if hasattr(w, "teardown"):
if widget is not None: w.teardown()
widget.deleteLater() w.deleteLater()
old_layout.deleteLater() self._form_grid_container.layout().removeWidget(self._form_grid)
self._form_grid.deleteLater() self._form_grid.deleteLater()
self._form_grid = QWidget() self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout()) self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid) self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size() self.update_size()
def update_size(self): def update_size(self):
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
self.adjustSize() self.adjustSize()
def _new_grid_layout(self): def _new_grid_layout(self):
new_grid = QGridLayout() new_grid = QGridLayout(self)
new_grid.setContentsMargins(0, 0, 0, 0) new_grid.setContentsMargins(0, 0, 0, 0)
return new_grid return new_grid
@@ -171,8 +170,9 @@ class TypedForm(BECWidget, QWidget):
class PydanticModelForm(TypedForm): class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict) form_data_updated = Signal(dict)
metadata_cleared = Signal(NoneType) form_data_cleared = Signal(NoneType)
validity_proc = Signal(bool)
def __init__( def __init__(
self, self,
@@ -204,7 +204,7 @@ class PydanticModelForm(TypedForm):
self._validity = CompactPopupWidget() self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore self._validity.label = "Validity" # type: ignore
self._validity.compact_show_popup.setIcon( self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False) material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
) )
@@ -264,16 +264,18 @@ class PydanticModelForm(TypedForm):
def validate_form(self, *_) -> bool: def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema. """validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true. If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false.""" Otherwise, emits on form_data_cleared and returns false."""
try: try:
metadata_dict = self.get_form_data() metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict) self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success") self._validity.set_global_state("success")
self._validity_message.setText("No errors!") self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict) self.form_data_updated.emit(metadata_dict)
self.validity_proc.emit(True)
return True return True
except ValidationError as e: except ValidationError as e:
self._validity.set_global_state("emergency") self._validity.set_global_state("emergency")
self._validity_message.setText(str(e)) self._validity_message.setText(str(e))
self.metadata_cleared.emit(None) self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
return False return False
+80 -15
View File
@@ -3,8 +3,20 @@ from __future__ import annotations
import typing import typing
from abc import abstractmethod from abc import abstractmethod
from decimal import Decimal from decimal import Decimal
from types import GenericAlias, UnionType from types import GenericAlias, NoneType, UnionType
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args from typing import (
Any,
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
Optional,
OrderedDict,
TypeVar,
get_args,
)
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
@@ -61,7 +73,7 @@ class FormItemSpec(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias item_type: type | UnionType | GenericAlias | Optional[Any]
name: str name: str
info: FieldInfo = FieldInfo() info: FieldInfo = FieldInfo()
pretty_display: bool = Field( pretty_display: bool = Field(
@@ -178,6 +190,10 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any """Add the main data entry widget to self._main_widget and appply any
constraints from the field info""" constraints from the field info"""
@SafeSlot()
def clear(self, *_):
return
def _set_pretty_display(self): def _set_pretty_display(self):
self.setEnabled(False) self.setEnabled(False)
if button := getattr(self, "_clear_button", None): if button := getattr(self, "_clear_button", None):
@@ -194,11 +210,17 @@ class DynamicFormItem(QWidget):
self._layout.addWidget(self._clear_button) self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required # the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.") self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore self._clear_button.clicked.connect(self.clear) # type: ignore
def _value_changed(self, *_, **__): def _value_changed(self, *_, **__):
self.valueChanged.emit() self.valueChanged.emit()
def teardown(self):
self._layout.deleteLater()
self._layout.removeWidget(self._main_widget)
self._main_widget.deleteLater()
self._main_widget = None
class StrFormItem(DynamicFormItem): class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
@@ -350,11 +372,13 @@ class DictFormItem(DynamicFormItem):
self._main_widget.replace_data(value) self._main_widget.replace_data(value)
class _ItemAndWidgetType(NamedTuple): _IW = TypeVar("_IW", bound=int | float | str)
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
item: type[_IW]
widget: type[QWidget] widget: type[QWidget]
default: int | float | str default: _IW
class ListFormItem(DynamicFormItem): class ListFormItem(DynamicFormItem):
@@ -390,17 +414,29 @@ class ListFormItem(DynamicFormItem):
def _add_buttons(self): def _add_buttons(self):
self._button_holder = QWidget() self._button_holder = QWidget()
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._buttons = QVBoxLayout() self._buttons = QVBoxLayout()
self._buttons.setContentsMargins(0, 0, 0, 0)
self._button_holder.setLayout(self._buttons) self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder) self._layout.addWidget(self._button_holder)
self._add_remove_button_holder = QWidget()
self._add_remove_button_layout = QHBoxLayout()
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
self._add_button = QPushButton("+") self._add_button = QPushButton("+")
self._add_button.setMinimumHeight(15)
self._add_button.setToolTip("add a new row") self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-") self._remove_button = QPushButton("-")
self._remove_button.setMinimumHeight(15)
self._remove_button.setToolTip("delete the focused row (if any)") self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row) self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row) self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button) self._buttons.addWidget(self._add_remove_button_holder)
self._add_remove_button_layout.addWidget(self._add_button)
self._add_remove_button_layout.addWidget(self._remove_button)
def _set_pretty_display(self): def _set_pretty_display(self):
super()._set_pretty_display() super()._set_pretty_display()
@@ -518,11 +554,14 @@ class StrLiteralFormItem(DynamicFormItem):
self._layout.addWidget(self._main_widget) self._layout.addWidget(self._main_widget)
def getValue(self): def getValue(self):
if self._main_widget.currentIndex() == -1:
return None
return self._main_widget.currentText() return self._main_widget.currentText()
def setValue(self, value: str | None): def setValue(self, value: str | None):
if value is None: if value is None:
self.clear() self.clear()
return
for i in range(self._main_widget.count()): for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value: if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i) self._main_widget.setCurrentIndex(i)
@@ -533,15 +572,39 @@ class StrLiteralFormItem(DynamicFormItem):
self._main_widget.setCurrentIndex(-1) self._main_widget.setCurrentIndex(-1)
class OptionalStrLiteralFormItem(StrLiteralFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(get_args(self._spec.info.annotation)[0])
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
def _is_string_literal(t: type):
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
def _is_optional_string_literal(t: type):
if not hasattr(t, "__args__"):
return False
if len(t.__args__) != 2:
return False
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
return True
return False
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | { DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict # dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order # and delete/insert keys or change the order
"literal_str": ( "literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
lambda spec: type(spec.info.annotation) is type(Literal[""]) "optional_literal_str": (
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str}, lambda spec: _is_optional_string_literal(spec.info.annotation),
StrLiteralFormItem, OptionalStrLiteralFormItem,
), ),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem), "str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem), "int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
@@ -592,6 +655,8 @@ if __name__ == "__main__": # pragma: no cover
value5: int | None = Field() value5: int | None = Field()
value6: list[int] = Field() value6: list[int] = Field()
value7: list = Field() value7: list = Field()
literal: Literal["a", "b", "c"]
nullable_literal: Literal["a", "b", "c"] | None = None
app = QApplication([]) app = QApplication([])
w = QWidget() w = QWidget()
@@ -599,7 +664,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout) w.setLayout(layout)
items = [] items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()): for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info) spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0) layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec) widg = widget_from_type(spec)(spec=spec)
items.append(widg) items.append(widg)
@@ -7,7 +7,7 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"] EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)" _PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)" _SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE) SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
{widget_import} {widget_import}
@@ -20,6 +21,8 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = {plugin_name_pascal}(parent) t = {plugin_name_pascal}(parent)
return t return t
+1 -1
View File
@@ -201,7 +201,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
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): if issubclass(obj, BECConnector):
class_info.is_connector = True class_info.is_connector = True
if issubclass(obj, BECWidget): if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
class_info.is_widget = True class_info.is_widget = True
if len(subs) == 1 and ( if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget) issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
+694
View File
@@ -0,0 +1,694 @@
from __future__ import annotations
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFontDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class PropertyEditor(QWidget):
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
super().__init__(parent)
self._target = target
self._bec_only = show_only_bec
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Name row
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(target.objectName())
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# BEC only checkbox
filter_row = QHBoxLayout()
self.chk_show_qt = QCheckBox("Show Qt properties")
self.chk_show_qt.setChecked(False)
filter_row.addWidget(self.chk_show_qt)
filter_row.addStretch(1)
layout.addLayout(filter_row)
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
# Main tree widget
self.tree = QTreeWidget(self)
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Property", "Value"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(False)
layout.addWidget(self.tree)
self._build()
def _class_chain(self):
chain = []
mo = self._target.metaObject()
while mo is not None:
chain.append(mo)
mo = mo.superClass()
return chain
def set_show_only_bec(self, flag: bool):
self._bec_only = flag
self._build()
def _set_equal_columns(self):
header = self.tree.header()
header.setSectionResizeMode(0, QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Interactive)
w = self.tree.viewport().width() or self.tree.width()
if w > 0:
half = max(1, w // 2)
self.tree.setColumnWidth(0, half)
self.tree.setColumnWidth(1, w - half)
def _build(self):
self.tree.clear()
for mo in self._class_chain():
class_name = mo.className()
if self._bec_only and not self._is_bec_metaobject(mo):
continue
group_item = QTreeWidgetItem(self.tree, [class_name])
group_item.setFirstColumnSpanned(True)
start = mo.propertyOffset()
end = mo.propertyCount()
for i in range(start, end):
prop = mo.property(i)
if (
not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
name = prop.name()
if name == "objectName":
continue
value = self._target.property(name)
self._add_property_row(group_item, name, value, prop)
if group_item.childCount() == 0:
idx = self.tree.indexOfTopLevelItem(group_item)
self.tree.takeTopLevelItem(idx)
self.tree.expandAll()
QTimer.singleShot(0, self._set_equal_columns)
def _enum_int(self, obj) -> int:
return int(getattr(obj, "value", obj))
def _make_sizepolicy_editor(self, name: str, sp):
if not isinstance(sp, QSizePolicy):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
h_combo = QComboBox(wrap)
v_combo = QComboBox(wrap)
hs = QSpinBox(wrap)
vs = QSpinBox(wrap)
for b in (hs, vs):
b.setRange(0, 16777215)
policies = [
(QSizePolicy.Fixed, "Fixed"),
(QSizePolicy.Minimum, "Minimum"),
(QSizePolicy.Maximum, "Maximum"),
(QSizePolicy.Preferred, "Preferred"),
(QSizePolicy.Expanding, "Expanding"),
(QSizePolicy.MinimumExpanding, "MinExpanding"),
(QSizePolicy.Ignored, "Ignored"),
]
for pol, text in policies:
h_combo.addItem(text, self._enum_int(pol))
v_combo.addItem(text, self._enum_int(pol))
def _set_current(combo, val):
idx = combo.findData(self._enum_int(val))
if idx >= 0:
combo.setCurrentIndex(idx)
_set_current(h_combo, sp.horizontalPolicy())
_set_current(v_combo, sp.verticalPolicy())
hs.setValue(sp.horizontalStretch())
vs.setValue(sp.verticalStretch())
def apply_changes():
hp = QSizePolicy.Policy(h_combo.currentData())
vp = QSizePolicy.Policy(v_combo.currentData())
nsp = QSizePolicy(hp, vp)
nsp.setHorizontalStretch(hs.value())
nsp.setVerticalStretch(vs.value())
self._target.setProperty(name, nsp)
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
hs.valueChanged.connect(lambda _=None: apply_changes())
vs.valueChanged.connect(lambda _=None: apply_changes())
row.addWidget(h_combo)
row.addWidget(v_combo)
row.addWidget(hs)
row.addWidget(vs)
return wrap
def _make_locale_editor(self, name: str, loc):
if not isinstance(loc, QLocale):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
lang_combo = QComboBox(wrap)
country_combo = QComboBox(wrap)
for lang in QLocale.Language:
try:
lang_int = self._enum_int(lang)
except Exception:
continue
if lang_int < 0:
continue
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
lang_combo.addItem(name_txt, lang_int)
def populate_countries():
country_combo.blockSignals(True)
country_combo.clear()
for terr in QLocale.Country:
try:
terr_int = self._enum_int(terr)
except Exception:
continue
if terr_int < 0:
continue
text = QLocale.countryToString(QLocale.Country(terr_int))
country_combo.addItem(text, terr_int)
cur_country = self._enum_int(loc.country())
idx = country_combo.findData(cur_country)
if idx >= 0:
country_combo.setCurrentIndex(idx)
country_combo.blockSignals(False)
cur_lang = self._enum_int(loc.language())
idx = lang_combo.findData(cur_lang)
if idx >= 0:
lang_combo.setCurrentIndex(idx)
populate_countries()
def apply_locale():
lang = QLocale.Language(int(lang_combo.currentData()))
country = QLocale.Country(int(country_combo.currentData()))
self._target.setProperty(name, QLocale(lang, country))
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
row.addWidget(lang_combo)
row.addWidget(country_combo)
return wrap
def _make_icon_editor(self, name: str, icon):
btn = QPushButton(self)
btn.setText("Choose…")
if isinstance(icon, QIcon) and not icon.isNull():
btn.setIcon(icon)
def pick():
path, _ = QFileDialog.getOpenFileName(
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
)
if path:
ic = QIcon(path)
self._target.setProperty(name, ic)
btn.setIcon(ic)
btn.clicked.connect(pick)
return btn
def _spin_pair(self, ints: bool = True):
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
if ints:
box1.setRange(-10_000_000, 10_000_000)
box2.setRange(-10_000_000, 10_000_000)
else:
for b in (box1, box2):
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
row.addWidget(box1)
row.addWidget(box2)
return wrap, box1, box2
def _spin_quad(self, ints: bool = True):
s = QSpinBox if ints else QDoubleSpinBox
boxes = [s(self) for _ in range(4)]
if ints:
for b in boxes:
b.setRange(-10_000_000, 10_000_000)
else:
for b in boxes:
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
for b in boxes:
row.addWidget(b)
return wrap, boxes
def _make_font_editor(self, name: str, value):
btn = QPushButton(self)
if isinstance(value, QFont):
btn.setText(f"{value.family()}, {value.pointSize()}pt")
else:
btn.setText("Select font…")
def pick():
ok, font = QFontDialog.getFont(
value if isinstance(value, QFont) else QFont(), self, "Select Font"
)
if ok:
self._target.setProperty(name, font)
btn.setText(f"{font.family()}, {font.pointSize()}pt")
btn.clicked.connect(pick)
return btn
def _make_color_editor(self, initial: QColor, apply_cb):
btn = QPushButton(self)
if isinstance(initial, QColor):
btn.setText(initial.name())
btn.setStyleSheet(f"background:{initial.name()};")
else:
btn.setText("Select color…")
def pick():
col = QColorDialog.getColor(
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
)
if col.isValid():
apply_cb(col)
btn.setText(col.name())
btn.setStyleSheet(f"background:{col.name()};")
btn.clicked.connect(pick)
return btn
def _apply_palette_color(
self,
name: str,
pal: QPalette,
group: QPalette.ColorGroup,
role: QPalette.ColorRole,
col: QColor,
):
pal.setColor(group, role, col)
self._target.setProperty(name, pal)
def _make_palette_editor(self, name: str, pal: QPalette):
if not isinstance(pal, QPalette):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
group_combo = QComboBox(wrap)
role_combo = QComboBox(wrap)
pick_btn = self._make_color_editor(
pal.color(QPalette.Active, QPalette.WindowText),
lambda col: self._apply_palette_color(
name, pal, QPalette.Active, QPalette.WindowText, col
),
)
groups = [
(QPalette.Active, "Active"),
(QPalette.Inactive, "Inactive"),
(QPalette.Disabled, "Disabled"),
]
for g, label in groups:
group_combo.addItem(label, int(getattr(g, "value", g)))
roles = [
(QPalette.WindowText, "WindowText"),
(QPalette.Window, "Window"),
(QPalette.Base, "Base"),
(QPalette.AlternateBase, "AlternateBase"),
(QPalette.ToolTipBase, "ToolTipBase"),
(QPalette.ToolTipText, "ToolTipText"),
(QPalette.Text, "Text"),
(QPalette.Button, "Button"),
(QPalette.ButtonText, "ButtonText"),
(QPalette.BrightText, "BrightText"),
(QPalette.Highlight, "Highlight"),
(QPalette.HighlightedText, "HighlightedText"),
]
for r, label in roles:
role_combo.addItem(label, int(getattr(r, "value", r)))
def rewire_button():
g = QPalette.ColorGroup(int(group_combo.currentData()))
r = QPalette.ColorRole(int(role_combo.currentData()))
col = pal.color(g, r)
while row.count() > 2:
w = row.takeAt(2).widget()
if w:
w.deleteLater()
btn = self._make_color_editor(
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
)
row.addWidget(btn)
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
row.addWidget(group_combo)
row.addWidget(role_combo)
row.addWidget(pick_btn)
return wrap
def _make_cursor_editor(self, name: str, value):
combo = QComboBox(self)
shapes = [
(Qt.ArrowCursor, "Arrow"),
(Qt.IBeamCursor, "IBeam"),
(Qt.WaitCursor, "Wait"),
(Qt.CrossCursor, "Cross"),
(Qt.UpArrowCursor, "UpArrow"),
(Qt.SizeAllCursor, "SizeAll"),
(Qt.PointingHandCursor, "PointingHand"),
(Qt.ForbiddenCursor, "Forbidden"),
(Qt.WhatsThisCursor, "WhatsThis"),
(Qt.BusyCursor, "Busy"),
]
current_shape = None
if isinstance(value, QCursor):
try:
enum_val = value.shape()
current_shape = int(getattr(enum_val, "value", enum_val))
except Exception:
current_shape = None
for shape, text in shapes:
combo.addItem(text, int(getattr(shape, "value", shape)))
if current_shape is not None:
idx = combo.findData(current_shape)
if idx >= 0:
combo.setCurrentIndex(idx)
def apply_index(i):
shape_val = int(combo.itemData(i))
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
combo.currentIndexChanged.connect(apply_index)
return combo
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
item = QTreeWidgetItem(parent, [name, ""])
editor = self._make_editor(name, value, prop)
if editor is not None:
self.tree.setItemWidget(item, 1, editor)
else:
item.setText(1, repr(value))
def _is_bec_metaobject(self, mo) -> bool:
cname = mo.className()
for cls in type(self._target).mro():
if getattr(cls, "__name__", None) == cname:
mod = getattr(cls, "__module__", "")
return mod.startswith("bec_widgets")
return False
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
if not meta_enum.isFlag():
key = meta_enum.valueToKey(value_int)
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
parts = []
for i in range(meta_enum.keyCount()):
k = meta_enum.key(i)
v = meta_enum.value(i)
if value_int & v:
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
parts.append(k)
return " | ".join(parts) if parts else "0"
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
try:
return int(value)
except Exception:
pass
v = getattr(value, "value", None)
if isinstance(v, (int,)):
return int(v)
n = getattr(value, "name", None)
if isinstance(n, str):
res = meta_enum.keyToValue(n)
if res != -1:
return int(res)
s = str(value)
parts = [p.strip() for p in s.replace(",", "|").split("|")]
keys = []
for p in parts:
if "." in p:
p = p.split(".")[-1]
keys.append(p)
keystr = "|".join(keys)
try:
res = meta_enum.keysToValue(keystr)
if res != -1:
return int(res)
except Exception:
pass
return 0
def _make_enum_editor(self, name: str, value, prop):
meta_enum = prop.enumerator()
current = self._enum_value_to_int(meta_enum, value)
if not meta_enum.isFlag():
combo = QComboBox(self)
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
combo.addItem(key, meta_enum.value(i))
idx = combo.findData(current)
if idx < 0:
txt = self._enum_text(meta_enum, current)
idx = combo.findText(txt)
combo.setCurrentIndex(max(idx, 0))
def apply_index(i):
v = combo.itemData(i)
self._target.setProperty(name, int(v))
combo.currentIndexChanged.connect(apply_index)
return combo
btn = QToolButton(self)
btn.setText(self._enum_text(meta_enum, current))
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
actions = []
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
act = menu.addAction(key)
act.setCheckable(True)
act.setChecked(bool(current & meta_enum.value(i)))
actions.append(act)
btn.setMenu(menu)
def apply_flags():
flags = 0
for i, act in enumerate(actions):
if act.isChecked():
flags |= meta_enum.value(i)
self._target.setProperty(name, int(flags))
btn.setText(self._enum_text(meta_enum, flags))
menu.triggered.connect(lambda _a: apply_flags())
return btn
def _make_editor(self, name: str, value, prop):
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
if prop.isEnumType():
return self._make_enum_editor(name, value, prop)
if isinstance(value, QColor):
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
if isinstance(value, QFont):
return self._make_font_editor(name, value)
if isinstance(value, QPalette):
return self._make_palette_editor(name, value)
if isinstance(value, QCursor):
return self._make_cursor_editor(name, value)
if isinstance(value, QSizePolicy):
ed = self._make_sizepolicy_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QLocale):
ed = self._make_locale_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QIcon):
ed = self._make_icon_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QSize):
wrap, w, h = self._spin_pair(ints=True)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
return wrap
if isinstance(value, QSizeF):
wrap, w, h = self._spin_pair(ints=False)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
return wrap
if isinstance(value, QPoint):
wrap, x, y = self._spin_pair(ints=True)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
return wrap
if isinstance(value, QPointF):
wrap, x, y = self._spin_pair(ints=False)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
return wrap
if isinstance(value, QRect):
wrap, boxes = self._spin_quad(ints=True)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rect():
self._target.setProperty(
name,
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rect())
return wrap
if isinstance(value, QRectF):
wrap, boxes = self._spin_quad(ints=False)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rectf():
self._target.setProperty(
name,
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rectf())
return wrap
if isinstance(value, bool):
w = QCheckBox(self)
w.setChecked(bool(value))
w.toggled.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, int) and not isinstance(value, bool):
w = QSpinBox(self)
w.setRange(-10_000_000, 10_000_000)
w.setValue(int(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, float):
w = QDoubleSpinBox(self)
w.setDecimals(6)
w.setRange(-1e12, 1e12)
w.setSingleStep(0.1)
w.setValue(float(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, str):
w = QLineEdit(self)
w.setText(value)
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
return w
return None
class DemoApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
layout = QHBoxLayout(self)
# Create a BECWidget instance example
waveform = self.create_waveform()
# property editor for the BECWidget
property_editor = PropertyEditor(waveform, show_only_bec=True)
layout.addWidget(waveform)
layout.addWidget(property_editor)
def create_waveform(self):
"""Create a new waveform widget."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
waveform = Waveform(parent=self)
waveform.title = "New Waveform"
waveform.x_label = "X Axis"
waveform.y_label = "Y Axis"
return waveform
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
demo = DemoApp()
demo.setWindowTitle("Property Editor Demo")
demo.resize(1200, 800)
demo.show()
sys.exit(app.exec())
+14
View File
@@ -13,3 +13,17 @@ def register_rpc_methods(cls):
if getattr(method, "rpc_public", False): if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name) cls.USER_ACCESS.add(name)
return cls return cls
def rpc_timeout(timeout: float | None):
"""
Decorator to set a timeout for RPC methods.
The actual implementation of timeout handling is within the cli module. This decorator
is solely to inform the generate-cli command about the timeout value.
"""
def decorator(func):
func.__rpc_timeout__ = timeout # Store the timeout value in the function
return func
return decorator
+42 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import time
import traceback import traceback
import types import types
from contextlib import contextmanager from contextlib import contextmanager
@@ -10,7 +11,7 @@ from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError from redis.exceptions import RedisError
@@ -128,16 +129,44 @@ class RPCServer:
# Run with rpc registry broadcast, but only once # Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast(): with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}") logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method) if method == "raise" and hasattr(
# check if the method accepts args and kwargs obj, "setWindowState"
if not callable(method_obj): ): # special case for raising windows, should work even if minimized
if not args: # this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
res = method_obj # The procedure is as follows:
else: # 1. Get the current window state to check if the window is minimized and remove minimized flag
setattr(obj, method, args[0]) # 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
res = None # and call raise_() and activateWindow()
# This forces gnome to raise the window even if focus stealing is prevented
# 3. Flag for stay on top is removed again to restore the original window state
# 4. Finally, we call show() to ensure the window is visible
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
target_state = state | Qt.WindowActive
if state & Qt.WindowMinimized:
target_state &= ~Qt.WindowMinimized
obj.setWindowState(target_state)
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
obj.showNormal()
if hasattr(obj, "raise_"):
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
obj.raise_()
if hasattr(obj, "activateWindow"):
obj.activateWindow()
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
obj.show()
res = None
else: else:
res = method_obj(*args, **kwargs) method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list): if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res] res = [self.serialize_object(obj) for obj in res]
@@ -200,6 +229,7 @@ class RPCServer:
MessageEndpoints.gui_registry_state(self.gui_id), MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)}, msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1, max_size=1,
expire=60,
) )
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict: def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
@@ -229,6 +259,8 @@ class RPCServer:
if wait: if wait:
while not self.rpc_register.object_is_registered(connector): while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents() QApplication.processEvents()
logger.info(f"Waiting for {connector} to be registered...")
time.sleep(0.1)
widget_class = getattr(connector, "rpc_widget_class", None) widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class: if not widget_class:
+17 -36
View File
@@ -1,44 +1,25 @@
from bec_lib.codecs import BECCodec
from bec_lib.serialization import msgpack from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF from qtpy.QtCore import QPointF
class QPointFEncoder(BECCodec):
obj_type = QPointF
@staticmethod
def encode(obj: QPointF) -> list[float]:
"""Encode a QPointF object to a list of floats."""
return [obj.x(), obj.y()]
@staticmethod
def decode(type_name: str, data: list[float]) -> list[float]:
"""No-op function since QPointF is encoded as a list of floats."""
return data
def register_serializer_extension(): def register_serializer_extension():
""" """
Register the serializer extension for the BECConnector. Register the serializer extension for the BECConnector.
""" """
if not module_is_registered("bec_widgets.utils.serialization"): if not msgpack.is_registered(QPointF):
msgpack.register_object_hook(encode_qpointf, decode_qpointf) msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)
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
+8 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -42,11 +43,15 @@ class PerformanceConnection(BundleConnection):
super().__init__() super().__init__()
self._connected = False self._connected = False
@SafeSlot(bool)
def set_fps_monitor(self, enabled: bool):
setattr(self.target_widget, "enable_fps_monitor", enabled)
def connect(self): def connect(self):
self._connected = True self._connected = True
# Connect the action to the target widget's method # Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect( self.components.get_action_reference("fps_monitor")().action.toggled.connect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked) self.set_fps_monitor
) )
def disconnect(self): def disconnect(self):
@@ -54,5 +59,6 @@ class PerformanceConnection(BundleConnection):
return return
# Disconnect the action from the target widget's method # Disconnect the action from the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect( self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked) self.set_fps_monitor
) )
self._connected = False
+42
View File
@@ -264,6 +264,48 @@ class WidgetIO:
return WidgetIO._handlers[base] return WidgetIO._handlers[base]
return None return None
@staticmethod
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
"""
Return widgets matching the given class (or class-name string).
Args:
widget_class: Either a QWidget subclass or its class-name as a string.
recursive: If True (default), traverse all top-level widgets and their children;
if False, scan app.allWidgets() for a flat list.
Returns:
List of QWidget instances matching the class or class-name.
"""
app = QApplication.instance()
if app is None:
raise RuntimeError("No QApplication instance found.")
# Match by class-name string
if isinstance(widget_class, str):
name = widget_class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if top.__class__.__name__ == name:
result.append(top)
result.extend(
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
)
return result
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
# Match by actual class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if isinstance(top, widget_class):
result.append(top)
result.extend(top.findChildren(widget_class))
return result
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
################## for exporting and importing widget hierarchies ################## ################## for exporting and importing widget hierarchies ##################
@@ -27,6 +27,7 @@ class AutoUpdates(BECMainWindow):
_default_dock: BECDock _default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"] USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True RPC = True
PLUGIN = False
# enforce that subclasses have the same rpc widget class # enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates" rpc_widget_class = "AutoUpdates"
@@ -0,0 +1 @@
{'files': ['dock_area.py']}
@@ -1,22 +1,19 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.dock import BECDockArea from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
<widget class='BECDockArea' name='dock_area'> <widget class='BECDockArea' name='bec_dock_area'>
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
@@ -24,6 +21,8 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = BECDockArea(parent) t = BECDockArea(parent)
return t return t
@@ -31,13 +30,13 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "BEC Plots" return "BEC Containers"
def icon(self): def icon(self):
return designer_material_icon(BECDockArea.ICON_NAME) return designer_material_icon(BECDockArea.ICON_NAME)
def includeFile(self): def includeFile(self):
return "dock_area" return "bec_dock_area"
def initialize(self, form_editor): def initialize(self, form_editor):
self._form_editor = form_editor self._form_editor = form_editor
@@ -52,7 +51,7 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "BECDockArea" return "BECDockArea"
def toolTip(self): def toolTip(self):
return "BECDockArea" return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -389,6 +389,7 @@ class BECDock(BECWidget, Dock):
if widget in self.widgets: if widget in self.widgets:
self.widgets.remove(widget) self.widgets.remove(widget)
widget.close() widget.close()
widget.deleteLater()
def delete_all(self): def delete_all(self):
""" """
@@ -28,6 +28,7 @@ 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.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image 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.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
@@ -70,6 +71,7 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock", "detach_dock",
"attach_all", "attach_all",
"save_state", "save_state",
"screenshot",
"restore_state", "restore_state",
] ]
@@ -154,6 +156,9 @@ class BECDockArea(BECWidget, QWidget):
filled=True, filled=True,
parent=self, parent=self,
), ),
"heatmap": MaterialIconAction(
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
),
}, },
), ),
) )
@@ -263,11 +268,16 @@ class BECDockArea(BECWidget, QWidget):
"restore_state", "restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self), MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
) )
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components) bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all") bundle.add_action("attach_all")
bundle.add_action("save_state") bundle.add_action("save_state")
bundle.add_action("restore_state") bundle.add_action("restore_state")
bundle.add_action("screenshot")
self.toolbar.add_bundle(bundle) self.toolbar.add_bundle(bundle)
def _hook_toolbar(self): def _hook_toolbar(self):
@@ -291,6 +301,9 @@ class BECDockArea(BECWidget, QWidget):
menu_plots.actions["motor_map"].action.triggered.connect( menu_plots.actions["motor_map"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap") lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
) )
menu_plots.actions["heatmap"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
)
# Menu Devices # Menu Devices
menu_devices.actions["scan_control"].action.triggered.connect( menu_devices.actions["scan_control"].action.triggered.connect(
@@ -326,6 +339,7 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.components.get_action("restore_state").action.triggered.connect( self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state self.restore_state
) )
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
@SafeSlot() @SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None: def _create_widget_from_toolbar(self, widget_name: str) -> None:
@@ -1 +0,0 @@
{'files': ['dock_area.py','dock.py']}
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())
@@ -0,0 +1,204 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
class CollapsibleSection(QWidget):
"""A widget that combines a header button with any content widget for collapsible sections
This widget contains a header button with a title and a content widget.
The content widget can be any QWidget. The header button can be expanded or collapsed.
The header also contains an "Add" button that is only visible when hovering over the section.
Signals:
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
onto another section for reordering.
Arguments are (source_title, target_title).
"""
section_reorder_requested = Signal(str, str) # (source_title, target_title)
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
self.setAcceptDrops(True)
self._expanded = True
# Setup layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
self.main_layout.setSpacing(0)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 4, 0)
header_layout.setSpacing(0)
# Create header button
self.header_button = QPushButton()
self.header_button.clicked.connect(self.toggle_expanded)
# Enable drag and drop for reordering
self.header_button.setAcceptDrops(True)
self.header_button.mousePressEvent = self._header_mouse_press_event
self.header_button.mouseMoveEvent = self._header_mouse_move_event
self.header_button.dragEnterEvent = self._header_drag_enter_event
self.header_button.dropEvent = self._header_drop_event
self.drag_start_position = None
# Add header to layout
header_layout.addWidget(self.header_button)
header_layout.addStretch()
self.header_add_button = QPushButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(20, 20)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
self._update_expanded_state()
def set_widget(self, widget):
"""Set the content widget for this collapsible section"""
# Remove existing content widget if any
if self.content_widget and self.content_widget.parent() == self:
self.main_layout.removeWidget(self.content_widget)
self.content_widget.close()
self.content_widget.deleteLater()
self.content_widget = widget
if self.content_widget:
self.main_layout.addWidget(self.content_widget)
self._update_expanded_state()
def _update_appearance(self):
"""Update the header button appearance based on expanded state"""
# Use material icons with consistent sizing to match tree items
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
self.header_button.setIcon(icon)
self.header_button.setText(self.title)
# Get theme colors
palette = get_theme_palette()
text_color = palette.text().color().name()
self.header_button.setStyleSheet(
f"""
QPushButton {{
font-weight: bold;
text-align: left;
margin: 0;
padding: 0px;
border: none;
background: transparent;
color: {text_color};
icon-size: 20px 20px;
}}
"""
)
def toggle_expanded(self):
"""Toggle the expanded state and update size policy"""
self.expanded = not self.expanded
self._update_expanded_state()
def _update_expanded_state(self):
"""Update the expanded state based on current state"""
self._update_appearance()
if self.expanded:
if self.content_widget:
self.content_widget.show()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
else:
if self.content_widget:
self.content_widget.hide()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
@SafeProperty(bool)
def expanded(self) -> bool:
"""Get the expanded state"""
return self._expanded
@expanded.setter
def expanded(self, value: bool):
"""Set the expanded state programmatically"""
if not isinstance(value, bool):
raise ValueError("Expanded state must be a boolean")
if self._expanded == value:
return
self._expanded = value
self._update_appearance()
def connect_add_button(self, slot):
"""Connect a slot to the add button's clicked signal.
Args:
slot: The function to call when the add button is clicked.
"""
self.header_add_button.clicked.connect(slot)
def _header_mouse_press_event(self, event):
"""Handle mouse press on header for drag start"""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_position = event.pos()
QPushButton.mousePressEvent(self.header_button, event)
def _header_mouse_move_event(self, event):
"""Handle mouse move to start drag operation"""
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
# Check if we've moved far enough to start a drag
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
self._start_drag()
QPushButton.mouseMoveEvent(self.header_button, event)
def _start_drag(self):
"""Start the drag operation with a properly aligned widget pixmap"""
drag = QDrag(self.header_button)
mime_data = QMimeData()
mime_data.setText(f"section:{self.title}")
drag.setMimeData(mime_data)
# Grab a pixmap of the widget
widget_pixmap = self.header_button.grab()
drag.setPixmap(widget_pixmap)
# Set the hotspot to where the mouse was pressed on the widget
drag.setHotSpot(self.drag_start_position)
drag.exec_(Qt.MoveAction)
def _header_drag_enter_event(self, event):
"""Handle drag enter on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
event.acceptProposedAction()
else:
event.ignore()
def _header_drop_event(self, event):
"""Handle drop on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
source_title = event.mimeData().text().replace("section:", "")
if source_title != self.title:
# Emit signal to parent to handle reordering
self.section_reorder_requested.emit(source_title, self.title)
event.acceptProposedAction()
else:
event.ignore()
@@ -0,0 +1,179 @@
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
class Explorer(BECWidget, QWidget):
"""
A widget that combines multiple collapsible sections for an explorer-like interface.
Each section can be expanded or collapsed, and sections can be reordered. The explorer
can contain also sub-explorers for nested structures.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
# Main layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# Splitter for sections
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.main_layout.addWidget(self.splitter)
# Spacer for when all sections are collapsed
self.expander = QSpacerItem(0, 0)
self.main_layout.addItem(self.expander)
# Registry of sections
self.sections: list[CollapsibleSection] = []
# Setup splitter styling
self._setup_splitter_styling()
def add_section(self, section: CollapsibleSection) -> None:
"""
Add a collapsible section to the explorer
Args:
section (CollapsibleSection): The section to add
"""
if not isinstance(section, CollapsibleSection):
raise TypeError("section must be an instance of CollapsibleSection")
if section in self.sections:
return
self.sections.append(section)
self.splitter.addWidget(section)
# Connect the section's toggle to update spacer
section.header_button.clicked.connect(self._update_spacer)
# Connect section reordering if supported
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.connect(self._handle_section_reorder)
self._update_spacer()
def remove_section(self, section: CollapsibleSection) -> None:
"""
Remove a collapsible section from the explorer
Args:
section (CollapsibleSection): The section to remove
"""
if section not in self.sections:
return
self.sections.remove(section)
section.deleteLater()
section.close()
# Disconnect signals
try:
section.header_button.clicked.disconnect(self._update_spacer)
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.disconnect(self._handle_section_reorder)
except RuntimeError:
# Signals already disconnected
pass
self._update_spacer()
def get_section(self, title: str) -> CollapsibleSection | None:
"""Get a section by its title"""
for section in self.sections:
if section.title == title:
return section
return None
def _setup_splitter_styling(self) -> None:
"""Setup the splitter styling with theme colors"""
palette = get_theme_palette()
separator_color = palette.mid().color()
self.splitter.setStyleSheet(
f"""
QSplitter::handle {{
height: 0.1px;
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
}}
"""
)
def _update_spacer(self) -> None:
"""Update the spacer size based on section states"""
any_expanded = any(section.expanded for section in self.sections)
if any_expanded:
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else:
self.expander.changeSize(
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
)
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
"""Handle reordering of sections"""
if source_title == target_title:
return
source_section = self.get_section(source_title)
target_section = self.get_section(target_title)
if not source_section or not target_section:
return
# Get current indices
source_index = self.splitter.indexOf(source_section)
target_index = self.splitter.indexOf(target_section)
if source_index == -1 or target_index == -1:
return
# Insert at target position
self.splitter.insertWidget(target_index, source_section)
# Update sections
self.sections.remove(source_section)
self.sections.insert(target_index, source_section)
if __name__ == "__main__":
import os
from qtpy.QtWidgets import QApplication, QLabel
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
app = QApplication([])
explorer = Explorer()
section = CollapsibleSection(title="SCRIPTS", indentation=0)
script_explorer = Explorer()
script_widget = ScriptTreeWidget()
local_scripts_section = CollapsibleSection(title="Local")
local_scripts_section.set_widget(script_widget)
script_widget.set_directory(os.path.abspath("./"))
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
explorer.add_section(section)
shared_script_section = CollapsibleSection(title="Shared")
shared_script_widget = ScriptTreeWidget()
shared_script_widget.set_directory(os.path.abspath("./"))
shared_script_section.set_widget(shared_script_widget)
script_explorer.add_section(shared_script_section)
macros_section = CollapsibleSection(title="MACROS", indentation=0)
macros_section.set_widget(QLabel("Macros will be implemented later"))
explorer.add_section(macros_section)
explorer.show()
app.exec()
@@ -0,0 +1,387 @@
import os
from pathlib import Path
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
from qtpy.QtGui import QAction, QPainter
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.file_actions: list[QAction] = []
self.dir_actions: list[QAction] = []
self.button_rects: list[QRect] = []
self.current_file_path = ""
def add_file_action(self, action: QAction) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
self.file_actions.clear()
self.dir_actions.clear()
def paint(self, painter, option, index):
"""Paint the item with action buttons on hover"""
# Paint the default item
super().paint(painter, option, index)
# Early return if not hovering over this item
if index != self.hovered_index:
return
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, QSortFilterProxyModel):
return
source_index = proxy_model.mapToSource(index)
source_model = proxy_model.sourceModel()
if not isinstance(source_model, QFileSystemModel):
return
is_dir = source_model.isDir(source_index)
file_path = source_model.filePath(source_index)
self.current_file_path = file_path
# Choose appropriate actions based on item type
actions = self.dir_actions if is_dir else self.file_actions
if actions:
self._draw_action_buttons(painter, option, actions)
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
spacing = 2
# Calculate total width needed for all buttons
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
# Clear previous button rects and create new ones
self.button_rects.clear()
# Calculate starting position (right side of the item)
start_x = option.rect.right() - total_width - margin
current_x = start_x
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Get theme colors for better integration
palette = get_theme_palette()
button_bg = palette.button().color()
button_bg.setAlpha(150) # Semi-transparent
for action in actions:
if not action.isVisible():
continue
# Calculate button position
button_rect = QRect(
current_x,
option.rect.top() + (option.rect.height() - button_size) // 2,
button_size,
button_size,
)
self.button_rects.append(button_rect)
# Draw button background
painter.setBrush(button_bg)
painter.setPen(palette.mid().color())
painter.drawRoundedRect(button_rect, 3, 3)
# Draw action icon
icon = action.icon()
if not icon.isNull():
icon_rect = button_rect.adjusted(2, 2, -2, -2)
icon.paint(painter, icon_rect)
# Move to next button position
current_x += button_size + spacing
painter.restore()
def editorEvent(self, event, model, option, index):
"""Handle mouse events for action buttons"""
# Early return if not a left click
if not (
event.type() == event.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton
):
return super().editorEvent(event, model, option, index)
# Early return if not a proxy model
if not isinstance(model, QSortFilterProxyModel):
return super().editorEvent(event, model, option, index)
source_index = model.mapToSource(index)
source_model = model.sourceModel()
# Early return if not a file system model
if not isinstance(source_model, QFileSystemModel):
return super().editorEvent(event, model, option, index)
is_dir = source_model.isDir(source_index)
actions = self.dir_actions if is_dir else self.file_actions
# Check which button was clicked
visible_actions = [action for action in actions if action.isVisible()]
for i, button_rect in enumerate(self.button_rects):
if button_rect.contains(event.pos()) and i < len(visible_actions):
# Trigger the action
visible_actions[i].trigger()
return True
return super().editorEvent(event, model, option, index)
def set_hovered_index(self, index):
"""Set the currently hovered index"""
self.hovered_index = index
class ScriptTreeWidget(QWidget):
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
file_selected = Signal(str) # Script file path selected
file_open_requested = Signal(str) # File open button clicked
file_renamed = Signal(str, str) # Old path, new path
def __init__(self, parent=None):
super().__init__(parent)
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)
# Create and set custom delegate
self.delegate = FileItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for files
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
action.action.triggered.connect(self._on_file_open_requested)
self.delegate.add_file_action(action.action)
# Remove unnecessary columns
self.tree.setColumnHidden(1, True) # Hide size column
self.tree.setColumnHidden(2, True) # Hide type column
self.tree.setColumnHidden(3, True) # Hide date modified column
# Apply BEC styling
self._apply_styling()
# Script specific properties
self.directory = None
# Connect signals
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
# Install event filter for hover tracking
self.tree.viewport().installEventFilter(self)
# Add to layout
layout.addWidget(self.tree)
def _apply_styling(self):
"""Apply styling to the tree widget"""
# Get theme colors for subtle tree lines
palette = get_theme_palette()
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
}}
QTreeView::branch {{
border-image: none;
background: transparent;
}}
QTreeView::item {{
border: none;
padding: 0px;
margin: 0px;
}}
QTreeView::item:hover {{
background: palette(midlight);
border: none;
padding: 0px;
margin: 0px;
text-decoration: none;
}}
QTreeView::item:selected {{
background: palette(highlight);
color: palette(highlighted-text);
}}
QTreeView::item:selected:hover {{
background: palette(highlight);
}}
"""
self.tree.setStyleSheet(tree_style)
def eventFilter(self, obj, event):
"""Handle mouse move events for hover tracking"""
# Early return if not the tree viewport
if obj != self.tree.viewport():
return super().eventFilter(obj, event)
if event.type() == event.Type.MouseMove:
index = self.tree.indexAt(event.pos())
if index.isValid():
self.delegate.set_hovered_index(index)
else:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
if event.type() == event.Type.Leave:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
return super().eventFilter(obj, event)
def set_directory(self, directory):
"""Set the scripts directory"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
self.tree.expandAll()
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
path_obj = Path(file_path)
# Only emit signal for Python files
if path_obj.suffix.lower() == ".py":
logger.info(f"Script selected: {file_path}")
self.file_selected.emit(file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
# Emit signal to open the file
logger.info(f"File open requested via double-click: {file_path}")
self.file_open_requested.emit(file_path)
def _on_file_open_requested(self):
"""Handle file open action triggered"""
logger.info("File open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
self.file_open_requested.emit(file_path)
def add_file_action(self, action: QAction) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_action(action)
def clear_actions(self) -> None:
"""Remove all actions from items"""
self.delegate.clear_actions()
def refresh(self):
"""Refresh the tree view"""
if self.directory is None:
return
self.model.setRootPath("") # Reset
root_index = self.model.setRootPath(self.directory)
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
def expand_all(self):
"""Expand all items in the tree"""
self.tree.expandAll()
def collapse_all(self):
"""Collapse all items in the tree"""
self.tree.collapseAll()
@@ -12,4 +12,4 @@ class BECWebLinksMixin:
@staticmethod @staticmethod
def open_bec_bug_report(): def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/") webbrowser.open("https://github.com/bec-project/bec_widgets/issues")
@@ -0,0 +1 @@
{'files': ['main_window.py']}
@@ -0,0 +1,73 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import QSize
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
DOM_XML = """
<ui language='c++'>
<widget class='BECMainWindow' name='bec_main_window'>
</widget>
</ui>
"""
class BECMainWindowPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
# We want to initialize BECMainWindow upon starting designer
t = BECMainWindow(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Containers"
def icon(self):
return designer_material_icon(BECMainWindow.ICON_NAME)
def includeFile(self):
return "bec_main_window"
def initialize(self, form_editor):
import os
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True)
app = QApplication.instance()
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
self._form_editor = form_editor
def isContainer(self):
return True
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECMainWindow"
def toolTip(self):
return "BECMainWindow"
def whatsThis(self):
return self.toolTip()
@@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
@@ -19,22 +19,30 @@ from qtpy.QtWidgets import (
import bec_widgets import bec_widgets
from bec_widgets.utils import UILoader from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme, set_theme
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker,
NotificationCentre,
NotificationIndicator,
)
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
# Ensure the application does not use the native menu bar on macOS to be consistent with linux development.
QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True)
class BECMainWindow(BECWidget, QMainWindow): class BECMainWindow(BECWidget, QMainWindow):
RPC = False RPC = True
PLUGIN = False PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds SCAN_PROGRESS_HEIGHT = 12 # px
def __init__( def __init__(
self, self,
@@ -50,6 +58,14 @@ class BECMainWindow(BECWidget, QMainWindow):
self.app = QApplication.instance() self.app = QApplication.instance()
self.status_bar = self.statusBar() self.status_bar = self.statusBar()
self.setWindowTitle(window_title) self.setWindowTitle(window_title)
# Notification Centre overlay
self.notification_centre = NotificationCentre(parent=self) # Notification layer
self.notification_broker = BECNotificationBroker()
self._nc_margin = 16
self._position_notification_centre()
# Init ui
self._init_ui() self._init_ui()
self._connect_to_theme_change() self._connect_to_theme_change()
@@ -58,6 +74,34 @@ class BECMainWindow(BECWidget, QMainWindow):
self.display_client_message, MessageEndpoints.client_info() self.display_client_message, MessageEndpoints.client_info()
) )
def setCentralWidget(self, widget: QWidget, qt_default: bool = False): # type: ignore[override]
"""
Reimplement QMainWindow.setCentralWidget so that the *main content*
widget always lives on the lower layer of the stacked layout that
hosts our notification overlays.
Args:
widget: The widget that should become the new central content.
qt_default: When *True* the call is forwarded to the base class so
that Qt behaves exactly as the original implementation (used
during __init__ when we first install ``self._full_content``).
"""
super().setCentralWidget(widget)
self.notification_centre.raise_()
self.statusBar().raise_()
def resizeEvent(self, event):
super().resizeEvent(event)
self._position_notification_centre()
def _position_notification_centre(self):
"""Keep the notification panel at a fixed margin top-right."""
if not hasattr(self, "notification_centre"):
return
margin = getattr(self, "_nc_margin", 16) # px
nc = self.notification_centre
nc.move(self.width() - nc.width() - margin, margin)
################################################################################ ################################################################################
# MainWindow Elements Initialization # MainWindow Elements Initialization
################################################################################ ################################################################################
@@ -94,6 +138,26 @@ class BECMainWindow(BECWidget, QMainWindow):
# Add scan_progress bar with display logic # Add scan_progress bar with display logic
self._add_scan_progress_bar() self._add_scan_progress_bar()
# Setup NotificationIndicator to bottom right of the status bar
self._add_notification_indicator()
################################################################################
# Notification indicator and Notification Centre helpers
def _add_notification_indicator(self):
"""
Add the notification indicator to the status bar and hook the signals.
"""
# Add the notification indicator to the status bar
self.notification_indicator = NotificationIndicator(self)
self.status_bar.addPermanentWidget(self.notification_indicator)
# Connect the notification broker to the indicator
self.notification_centre.counts_updated.connect(self.notification_indicator.update_counts)
self.notification_indicator.filter_changed.connect(self.notification_centre.apply_filter)
self.notification_indicator.show_all_requested.connect(self.notification_centre.show_all)
self.notification_indicator.hide_all_requested.connect(self.notification_centre.hide_all)
################################################################################ ################################################################################
# Client message status bar widget helpers # Client message status bar widget helpers
@@ -137,8 +201,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_simple.show_remaining_time = False self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = "" self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8) self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80) self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
self._scan_progress_bar_full = ScanProgressBar(self) self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget( self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
@@ -155,62 +219,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_with_separator.layout.addWidget(separator) self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover) self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator) self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None: def _add_separator(self, separate_object: bool = False) -> QWidget | None:
""" """
Add a vertically centred separator to the status bar or just return it as a separate object. Add a vertically centred separator to the status bar or just return it as a separate object.
@@ -379,12 +389,12 @@ class BECMainWindow(BECWidget, QMainWindow):
@SafeSlot(str) @SafeSlot(str)
def change_theme(self, theme: str): def change_theme(self, theme: str):
""" """
Change the theme of the application. Change the theme of the application and propagate it to widgets.
Args: Args:
theme(str): The theme to apply, either "light" or "dark". theme(str): Either "light" or "dark".
""" """
apply_theme(theme) set_theme(theme) # emits theme_updated and applies palette globally
def event(self, event): def event(self, event):
if event.type() == QEvent.Type.StatusTip: if event.type() == QEvent.Type.StatusTip:
@@ -410,8 +420,6 @@ class BECMainWindow(BECWidget, QMainWindow):
# Timer cleanup # Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop() self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
######################################## ########################################
# Status bar widgets cleanup # Status bar widgets cleanup
@@ -430,15 +438,16 @@ class BECMainWindow(BECWidget, QMainWindow):
super().cleanup() super().cleanup()
class UILaunchWindow(BECMainWindow): class BECMainWindowNoRPC(BECMainWindow):
RPC = True RPC = False
PLUGIN = False
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
app = QApplication(sys.argv) app = QApplication(sys.argv)
main_window = UILaunchWindow() main_window = BECMainWindow()
main_window.show() main_window.show()
main_window.resize(800, 600) main_window.resize(800, 600)
sys.exit(app.exec()) sys.exit(app.exec())
@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.containers.main_window.bec_main_window_plugin import (
BECMainWindowPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMainWindowPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
@@ -20,6 +21,8 @@ class AbortButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = AbortButton(parent) t = AbortButton(parent)
return t return t
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
@@ -20,6 +21,8 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResetButton(parent) t = ResetButton(parent)
return t return t
@@ -48,7 +51,7 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ResetButton" return "ResetButton"
def toolTip(self): def toolTip(self):
return "A button that reset the scan queue." return "A button that resets the scan queue."
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
@@ -20,6 +21,8 @@ class ResumeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResumeButton(parent) t = ResumeButton(parent)
return t return t
@@ -1,10 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
@@ -15,8 +14,6 @@ DOM_XML = """
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
@@ -24,6 +21,8 @@ class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = StopButton(parent) t = StopButton(parent)
return t return t
@@ -1,10 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import ( from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator, PositionIndicator,
@@ -17,8 +16,6 @@ DOM_XML = """
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
@@ -26,6 +23,8 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionIndicator(parent) t = PositionIndicator(parent)
return t return t
@@ -54,7 +53,7 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
return "PositionIndicator" return "PositionIndicator"
def toolTip(self): def toolTip(self):
return "PositionIndicator" return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -88,7 +88,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
if not self._check_device_is_valid(device): if not self._check_device_is_valid(device):
return return
data = self.dev[device].read() data = self.dev[device].read(cached=True)
self._on_device_readback( self._on_device_readback(
device, device,
self._device_ui_components(device), self._device_ui_components(device),
@@ -138,7 +138,11 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
signals = msg_content.get("signals", {}) signals = msg_content.get("signals", {})
# pylint: disable=protected-access # pylint: disable=protected-access
hinted_signals = self.dev[device]._hints hinted_signals = self.dev[device]._hints
precision = self.dev[device].precision precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
spinner = ui_components["spinner"] spinner = ui_components["spinner"]
position_indicator = ui_components["position_indicator"] position_indicator = ui_components["position_indicator"]
@@ -178,11 +182,13 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
spinner.setVisible(False) spinner.setVisible(False)
if readback_val is not None: if readback_val is not None:
readback.setText(f"{readback_val:.{precision}f}") text = f"{readback_val:.{precision}f}"
readback.setText(text)
position_emit(readback_val) position_emit(readback_val)
if setpoint_val is not None: if setpoint_val is not None:
setpoint.setText(f"{setpoint_val:.{precision}f}") text = f"{setpoint_val:.{precision}f}"
setpoint.setText(text)
limits = self.dev[device].limits limits = self.dev[device].limits
limit_update(limits) limit_update(limits)
@@ -205,10 +211,13 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
ui["readback"].setToolTip(f"{device} readback") ui["readback"].setToolTip(f"{device} readback")
ui["setpoint"].setToolTip(f"{device} setpoint") ui["setpoint"].setToolTip(f"{device} setpoint")
ui["step_size"].setToolTip(f"Step size for {device}") ui["step_size"].setToolTip(f"Step size for {device}")
precision = self.dev[device].precision precision = getattr(self.dev[device], "precision", 8)
if precision is not None: try:
ui["step_size"].setDecimals(precision) precision = int(precision)
ui["step_size"].setValue(10**-precision * 10) except (TypeError, ValueError):
precision = int(8)
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
def _swap_readback_signal_connection(self, slot, old_device, new_device): def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device)) self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
PLUGIN = True PLUGIN = True
RPC = True RPC = True
USER_ACCESS = ["set_positioner"] USER_ACCESS = ["set_positioner", "screenshot"]
device_changed = Signal(str, str) device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update # Signal emitted to inform listeners about a position update
position_update = Signal(float) position_update = Signal(float)
@@ -1,12 +1,13 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
PositionerBox,
)
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -14,7 +15,6 @@ DOM_XML = """
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,6 +23,8 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerBox(parent) t = PositionerBox(parent)
return t return t
@@ -30,7 +32,7 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "Device Control" return "BEC Device Control"
def icon(self): def icon(self):
return designer_material_icon(PositionerBox.ICON_NAME) return designer_material_icon(PositionerBox.ICON_NAME)
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import ( from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
@@ -22,6 +23,8 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerBox2D(parent) t = PositionerBox2D(parent)
return t return t
@@ -29,7 +32,7 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "Device Control" return "BEC Device Control"
def icon(self): def icon(self):
return designer_material_icon(PositionerBox2D.ICON_NAME) return designer_material_icon(PositionerBox2D.ICON_NAME)
@@ -34,7 +34,15 @@ class PositionerBox2D(PositionerBoxBase):
PLUGIN = True PLUGIN = True
RPC = True RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"] USER_ACCESS = [
"set_positioner_hor",
"set_positioner_ver",
"screenshot",
"enable_controls_hor",
"enable_controls_hor.setter",
"enable_controls_ver",
"enable_controls_ver.setter",
]
device_changed_hor = Signal(str, str) device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str) device_changed_ver = Signal(str, str)
@@ -63,6 +71,8 @@ class PositionerBox2D(PositionerBoxBase):
self._limits_hor = None self._limits_hor = None
self._limits_ver = None self._limits_ver = None
self._dialog = None self._dialog = None
self._enable_controls_hor = True
self._enable_controls_ver = True
if self.current_path == "": if self.current_path == "":
self.current_path = os.path.dirname(__file__) self.current_path = os.path.dirname(__file__)
self.init_ui() self.init_ui()
@@ -281,6 +291,7 @@ class PositionerBox2D(PositionerBoxBase):
self.on_device_readback_hor, self.on_device_readback_hor,
self._device_ui_components_hv("horizontal"), self._device_ui_components_hv("horizontal"),
) )
self._apply_controls_enabled("horizontal")
@SafeSlot(str, str) @SafeSlot(str, str)
def on_device_change_ver(self, old_device: str, new_device: str): def on_device_change_ver(self, old_device: str, new_device: str):
@@ -300,6 +311,7 @@ class PositionerBox2D(PositionerBoxBase):
self.on_device_readback_ver, self.on_device_readback_ver,
self._device_ui_components_hv("vertical"), self._device_ui_components_hv("vertical"),
) )
self._apply_controls_enabled("vertical")
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents: def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
if device == "horizontal": if device == "horizontal":
@@ -337,6 +349,25 @@ class PositionerBox2D(PositionerBoxBase):
if device == self.device_ver: if device == self.device_ver:
return self._device_ui_components_hv("vertical") return self._device_ui_components_hv("vertical")
def _apply_controls_enabled(self, axis: DeviceId):
state = self._enable_controls_hor if axis == "horizontal" else self._enable_controls_ver
if axis == "horizontal":
widgets = [
self.ui.tweak_increase_hor,
self.ui.tweak_decrease_hor,
self.ui.step_increase_hor,
self.ui.step_decrease_hor,
]
else:
widgets = [
self.ui.tweak_increase_ver,
self.ui.tweak_decrease_ver,
self.ui.step_increase_ver,
self.ui.step_decrease_ver,
]
for w in widgets:
w.setEnabled(state)
@SafeSlot(dict, dict) @SafeSlot(dict, dict)
def on_device_readback_hor(self, msg_content: dict, metadata: dict): def on_device_readback_hor(self, msg_content: dict, metadata: dict):
"""Callback for device readback. """Callback for device readback.
@@ -417,6 +448,26 @@ class PositionerBox2D(PositionerBoxBase):
"""Step size for tweak""" """Step size for tweak"""
self.ui.step_size_ver.setValue(val) self.ui.step_size_ver.setValue(val)
@SafeProperty(bool)
def enable_controls_hor(self) -> bool:
"""Persisted switch for horizontal control buttons (tweak/step)."""
return self._enable_controls_hor
@enable_controls_hor.setter
def enable_controls_hor(self, value: bool):
self._enable_controls_hor = value
self._apply_controls_enabled("horizontal")
@SafeProperty(bool)
def enable_controls_ver(self) -> bool:
"""Persisted switch for vertical control buttons (tweak/step)."""
return self._enable_controls_ver
@enable_controls_ver.setter
def enable_controls_ver(self, value: bool):
self._enable_controls_ver = value
self._apply_controls_enabled("vertical")
@SafeSlot() @SafeSlot()
def on_tweak_inc_hor(self): def on_tweak_inc_hor(self):
"""Tweak device a up""" """Tweak device a up"""
@@ -1,12 +1,13 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box import PositionerControlLine from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -14,7 +15,6 @@ DOM_XML = """
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no cover class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,6 +23,8 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerControlLine(parent) t = PositionerControlLine(parent)
return t return t
@@ -30,7 +32,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
return DOM_XML return DOM_XML
def group(self): def group(self):
return "Device Control" return "BEC Device Control"
def icon(self): def icon(self):
return designer_material_icon(PositionerControlLine.ICON_NAME) return designer_material_icon(PositionerControlLine.ICON_NAME)
@@ -51,7 +53,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
return "PositionerControlLine" return "PositionerControlLine"
def toolTip(self): def toolTip(self):
return "A widget that controls a single positioner in line form." return "A widget that controls a single device."
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -15,7 +15,7 @@ logger = bec_logger.logger
class PositionerGroupBox(QGroupBox): class PositionerGroupBox(QGroupBox):
PLUGIN = True
position_update = Signal(float) position_update = Signal(float)
def __init__(self, parent, dev_name): def __init__(self, parent, dev_name):
@@ -45,7 +45,12 @@ class PositionerGroupBox(QGroupBox):
def _on_position_update(self, pos: float): def _on_position_update(self, pos: float):
self.position_update.emit(pos) self.position_update.emit(pos)
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % pos precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
self.widget.label = f"{pos:.{precision}f}"
def close(self): def close(self):
self.widget.close() self.widget.close()
@@ -55,6 +60,7 @@ class PositionerGroupBox(QGroupBox):
class PositionerGroup(BECWidget, QWidget): class PositionerGroup(BECWidget, QWidget):
"""Simple Widget to control a positioner in box form""" """Simple Widget to control a positioner in box form"""
PLUGIN = True
ICON_NAME = "grid_view" ICON_NAME = "grid_view"
USER_ACCESS = ["set_positioners"] USER_ACCESS = ["set_positioners"]
@@ -1,9 +1,8 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import ( from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
@@ -16,7 +15,6 @@ DOM_XML = """
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -25,6 +23,8 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerGroup(parent) t = PositionerGroup(parent)
return t return t
@@ -32,7 +32,7 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "Device Control" return "BEC Device Control"
def icon(self): def icon(self):
return designer_material_icon(PositionerGroup.ICON_NAME) return designer_material_icon(PositionerGroup.ICON_NAME)
@@ -53,7 +53,7 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "PositionerGroup" return "PositionerGroup"
def toolTip(self): def toolTip(self):
return "Container Widget to control positioners in compact form, in a grid" return "Simple Widget to control a positioner in box form"
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -112,7 +112,9 @@ class DeviceInputBase(BECWidget):
WidgetIO.set_value(widget=self, value=device) WidgetIO.set_value(widget=self, value=device)
self.config.default = device self.config.default = device
else: else:
logger.warning(f"Device {device} is not in the filtered selection.") logger.warning(
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
)
@SafeSlot() @SafeSlot()
def update_devices_from_filters(self): def update_devices_from_filters(self):
@@ -131,7 +133,8 @@ class DeviceInputBase(BECWidget):
# Filter based on readout priority # Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)] devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs] self.devices = [device.name for device in devs]
self.set_device(current_device) if current_device != "":
self.set_device(current_device)
@SafeSlot(list) @SafeSlot(list)
def set_available_devices(self, devices: list[str]): def set_available_devices(self, devices: list[str]):
@@ -69,7 +69,7 @@ class DeviceSignalInputBase(BECWidget):
Args: Args:
signal (str): signal name. signal (str): signal name.
""" """
if self.validate_signal(signal) is True: if self.validate_signal(signal):
WidgetIO.set_value(widget=self, value=signal) WidgetIO.set_value(widget=self, value=signal)
self.config.default = signal self.config.default = signal
else: else:
@@ -1,3 +1 @@
{ {'files': ['device_combobox.py']}
"files": ["device_combobox.py"]
}
@@ -1,22 +1,19 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
<widget class='DeviceComboBox' name='device_combobox'> <widget class='DeviceComboBox' name='device_combo_box'>
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
@@ -24,6 +21,8 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceComboBox(parent) t = DeviceComboBox(parent)
return t return t
@@ -37,7 +36,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return designer_material_icon(DeviceComboBox.ICON_NAME) return designer_material_icon(DeviceComboBox.ICON_NAME)
def includeFile(self): def includeFile(self):
return "device_combobox" return "device_combo_box"
def initialize(self, form_editor): def initialize(self, form_editor):
self._form_editor = form_editor self._form_editor = form_editor
@@ -52,7 +51,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "DeviceComboBox" return "DeviceComboBox"
def toolTip(self): def toolTip(self):
return "Device ComboBox Example for BEC Widgets" return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -5,6 +5,7 @@ from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter, BECDeviceFilter,
DeviceInputBase, DeviceInputBase,
@@ -61,6 +62,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._callback_id = None self._callback_id = None
self._is_valid_input = False self._is_valid_input = False
self._accent_colors = get_accent_colors() self._accent_colors = get_accent_colors()
self._set_first_element_as_empty = False
# We do not consider the config that is passed here, this produced problems # We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly # with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed. # Implementing this logic and config recoverage is postponed.
@@ -93,6 +95,31 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.currentTextChanged.connect(self.check_validity) self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText()) self.check_validity(self.currentText())
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def on_device_update(self, action: str, content: dict) -> None: def on_device_update(self, action: str, content: dict) -> None:
""" """
Callback for device update events. Triggers the device_update signal. Callback for device update events. Triggers the device_update signal.
@@ -1,3 +1 @@
{ {'files': ['device_line_edit.py']}
"files": ["device_line_edit.py"]
}
@@ -1,10 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit, DeviceLineEdit,
@@ -17,8 +16,6 @@ DOM_XML = """
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
@@ -26,6 +23,8 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceLineEdit(parent) t = DeviceLineEdit(parent)
return t return t
@@ -54,7 +53,7 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "DeviceLineEdit" return "DeviceLineEdit"
def toolTip(self): def toolTip(self):
return "Device LineEdit Example for BEC Widgets with autocomplete." return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
@@ -20,6 +21,8 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = SignalComboBox(parent) t = SignalComboBox(parent)
return t return t
@@ -48,7 +51,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalComboBox" return "SignalComboBox"
def toolTip(self): def toolTip(self):
return "Signal ComboBox Example for BEC Widgets with autocomplete." return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -1,8 +1,10 @@
from __future__ import annotations
from bec_lib.device import Positioner from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
@@ -54,6 +56,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0)) self.setMinimumSize(QSize(100, 0))
self._set_first_element_as_empty = True
# We do not consider the config that is passed here, this produced problems # We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly # with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed. # Implementing this logic and config recoverage is postponed.
@@ -90,6 +93,31 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals") self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False) self.model().item(0).setEnabled(False)
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def set_to_obj_name(self, obj_name: str) -> bool: def set_to_obj_name(self, obj_name: str) -> bool:
""" """
Set the combobox to the object name of the signal. Set the combobox to the object name of the signal.
@@ -142,6 +170,10 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return return
self.device_signal_changed.emit(text) self.device_signal_changed.emit(text)
@property
def selected_signal_comp_name(self) -> str:
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit import ( from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit import (
@@ -22,6 +23,8 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = SignalLineEdit(parent) t = SignalLineEdit(parent)
return t return t
@@ -50,7 +53,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalLineEdit" return "SignalLineEdit"
def toolTip(self): def toolTip(self):
return "Signal LineEdit Example for BEC Widgets with autocomplete." return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -0,0 +1,631 @@
"""Module with the device table view implementation."""
from __future__ import annotations
import copy
import json
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
from thefuzz import fuzz
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
# Threshold for fuzzy matching, careful with adjusting this. 80 seems good
FUZZY_SEARCH_THRESHOLD = 80
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
@staticmethod
def dict_to_str(d: dict) -> str:
"""Convert a dictionary to a formatted string."""
return json.dumps(d, indent=4)
def helpEvent(self, event, view, option, index):
"""Override to show tooltip when hovering."""
if event.type() != QtCore.QEvent.ToolTip:
return super().helpEvent(event, view, option, index)
model: DeviceFilterProxyModel = index.model()
model_index = model.mapToSource(index)
row_dict = model.sourceModel().row_data(model_index)
row_dict.pop("description", None)
QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view)
return True
class CenterCheckBoxDelegate(DictToolTipDelegate):
"""Custom checkbox delegate to center checkboxes in table cells."""
def __init__(self, parent=None):
super().__init__(parent)
colors = get_accent_colors()
self._icon_checked = material_icon(
"check_box", size=QtCore.QSize(16, 16), color=colors.default
)
self._icon_unchecked = material_icon(
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default
)
def apply_theme(self, theme: str | None = None):
colors = get_accent_colors()
self._icon_checked.setColor(colors.default)
self._icon_unchecked.setColor(colors.default)
def paint(self, painter, option, index):
value = index.model().data(index, QtCore.Qt.CheckStateRole)
if value is None:
super().paint(painter, option, index)
return
# Choose icon based on state
pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked
# Draw icon centered
rect = option.rect
pix_rect = pixmap.rect()
pix_rect.moveCenter(rect.center())
painter.drawPixmap(pix_rect.topLeft(), pixmap)
def editorEvent(self, event, model, option, index):
if event.type() != QtCore.QEvent.MouseButtonRelease:
return False
current = model.data(index, QtCore.Qt.CheckStateRole)
new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked
return model.setData(index, new_state, QtCore.Qt.CheckStateRole)
class WrappingTextDelegate(DictToolTipDelegate):
"""Custom delegate for wrapping text in table cells."""
def paint(self, painter, option, index):
text = index.model().data(index, QtCore.Qt.DisplayRole)
if not text:
return super().paint(painter, option, index)
painter.save()
painter.setClipRect(option.rect)
text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop
painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text)
painter.restore()
def sizeHint(self, option, index):
text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "")
# if not text:
# return super().sizeHint(option, index)
# Use the actual column width
table = index.model().parent() # or store reference to QTableView
column_width = table.columnWidth(index.column()) # - 8
doc = QtGui.QTextDocument()
doc.setDefaultFont(option.font)
doc.setTextWidth(column_width)
doc.setPlainText(text)
layout_height = doc.documentLayout().documentSize().height()
height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off
return QtCore.QSize(column_width, height)
class DeviceTableModel(QtCore.QAbstractTableModel):
"""
Custom Device Table Model for managing device configurations.
Sort logic is implemented directly on the data of the table view.
"""
def __init__(self, device_config: list[dict] | None = None, parent=None):
super().__init__(parent)
self._device_config = device_config or []
self.headers = [
"name",
"deviceClass",
"readoutPriority",
"enabled",
"readOnly",
"deviceTags",
"description",
]
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
###############################################
########## Overwrite custom Qt methods ########
###############################################
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
return len(self._device_config)
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
return len(self.headers)
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal:
return self.headers[section]
return None
def row_data(self, index: QtCore.QModelIndex) -> dict:
"""Return the row data for the given index."""
if not index.isValid():
return {}
return copy.deepcopy(self._device_config[index.row()])
def data(self, index, role=QtCore.Qt.DisplayRole):
"""Return data for the given index and role."""
if not index.isValid():
return None
row, col = index.row(), index.column()
key = self.headers[col]
value = self._device_config[row].get(key)
if role == QtCore.Qt.DisplayRole:
if key in ("enabled", "readOnly"):
return bool(value)
if key == "deviceTags":
return ", ".join(str(tag) for tag in value) if value else ""
return str(value) if value is not None else ""
if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"):
return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
if role == QtCore.Qt.TextAlignmentRole:
if key in ("enabled", "readOnly"):
return QtCore.Qt.AlignCenter
return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
if role == QtCore.Qt.FontRole:
font = QtGui.QFont()
return font
return None
def flags(self, index):
"""Flags for the table model."""
if not index.isValid():
return QtCore.Qt.NoItemFlags
key = self.headers[index.column()]
if key in ("enabled", "readOnly"):
base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
if self._checkable_columns_enabled.get(key, True):
return base_flags | QtCore.Qt.ItemIsUserCheckable
else:
return base_flags # disable editing but still visible
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool:
"""
Method to set the data of the table.
Args:
index (QModelIndex): The index of the item to modify.
value (Any): The new value to set.
role (Qt.ItemDataRole): The role of the data being set.
Returns:
bool: True if the data was set successfully, False otherwise.
"""
if not index.isValid():
return False
key = self.headers[index.column()]
row = index.row()
if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole:
if not self._checkable_columns_enabled.get(key, True):
return False # ignore changes if column is disabled
self._device_config[row][key] = value == QtCore.Qt.Checked
self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole])
return True
return False
####################################
############ Public methods ########
####################################
def get_device_config(self) -> list[dict]:
"""Return the current device config (with checkbox updates applied)."""
return self._device_config
def set_checkbox_enabled(self, column_name: str, enabled: bool):
"""
Enable/Disable the checkbox column.
Args:
column_name (str): The name of the column to modify.
enabled (bool): Whether the checkbox should be enabled or disabled.
"""
if column_name in self._checkable_columns_enabled:
self._checkable_columns_enabled[column_name] = enabled
col = self.headers.index(column_name)
top_left = self.index(0, col)
bottom_right = self.index(self.rowCount() - 1, col)
self.dataChanged.emit(
top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole]
)
def set_device_config(self, device_config: list[dict]):
"""
Replace the device config.
Args:
device_config (list[dict]): The new device config to set.
"""
self.beginResetModel()
self._device_config = list(device_config)
self.endResetModel()
@SafeSlot(dict)
def add_device(self, device: dict):
"""
Add an extra device to the device config at the bottom.
Args:
device (dict): The device configuration to add.
"""
row = len(self._device_config)
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self._device_config.append(device)
self.endInsertRows()
@SafeSlot(int)
def remove_device_by_row(self, row: int):
"""
Remove one device row by index. This maps to the row to the source of the data model
Args:
row (int): The index of the device row to remove.
"""
if 0 <= row < len(self._device_config):
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
self._device_config.pop(row)
self.endRemoveRows()
@SafeSlot(list)
def remove_devices_by_rows(self, rows: list[int]):
"""
Remove multiple device rows by their indices.
Args:
rows (list[int]): The indices of the device rows to remove.
"""
for row in sorted(rows, reverse=True):
self.remove_device_by_row(row)
@SafeSlot(str)
def remove_device_by_name(self, name: str):
"""
Remove one device row by name.
Args:
name (str): The name of the device to remove.
"""
for row, device in enumerate(self._device_config):
if device.get("name") == name:
self.remove_device_by_row(row)
break
class BECTableView(QtWidgets.QTableView):
"""Table View with custom keyPressEvent to delete rows with backspace or delete key"""
def keyPressEvent(self, event) -> None:
"""
Delete selected rows with backspace or delete key
Args:
event: keyPressEvent
"""
if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
return super().keyPressEvent(event)
proxy_indexes = self.selectedIndexes()
if not proxy_indexes:
return
# Get unique rows (proxy indices) in reverse order so removal indexes stay valid
proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True)
# Map to source model rows
source_rows = [
self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows
]
model: DeviceTableModel = self.model().sourceModel() # access underlying model
# Delegate confirmation and removal to helper
removed = self._confirm_and_remove_rows(model, source_rows)
if not removed:
return
def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool:
"""
Prompt the user to confirm removal of rows and remove them from the model if accepted.
Returns True if rows were removed, False otherwise.
"""
cfg = model.get_device_config()
names = [str(cfg[r].get("name", "<unknown>")) for r in sorted(source_rows)]
msg = QtWidgets.QMessageBox(self)
msg.setIcon(QtWidgets.QMessageBox.Warning)
msg.setWindowTitle("Confirm remove devices")
if len(names) == 1:
msg.setText(f"Remove device '{names[0]}'?")
else:
msg.setText(f"Remove {len(names)} devices?")
msg.setInformativeText("\n".join(names))
msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)
msg.setDefaultButton(QtWidgets.QMessageBox.Cancel)
res = msg.exec_()
if res == QtWidgets.QMessageBox.Ok:
model.remove_devices_by_rows(source_rows)
# TODO add signal for removed devices
return True
return False
class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._hidden_rows = set()
self._filter_text = ""
self._enable_fuzzy = True
self._filter_columns = [0, 1] # name and deviceClass for search
def hide_rows(self, row_indices: list[int]):
"""
Hide specific rows in the model.
Args:
row_indices (list[int]): List of row indices to hide.
"""
self._hidden_rows.update(row_indices)
self.invalidateFilter()
def show_rows(self, row_indices: list[int]):
"""
Show specific rows in the model.
Args:
row_indices (list[int]): List of row indices to show.
"""
self._hidden_rows.difference_update(row_indices)
self.invalidateFilter()
def show_all_rows(self):
"""
Show all rows in the model.
"""
self._hidden_rows.clear()
self.invalidateFilter()
@SafeSlot(int)
def disable_fuzzy_search(self, enabled: int):
self._enable_fuzzy = not bool(enabled)
self.invalidateFilter()
def setFilterText(self, text: str):
self._filter_text = text.lower()
self.invalidateFilter()
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
# No hidden rows, and no filter text
if not self._filter_text and not self._hidden_rows:
return True
# Hide hidden rows
if source_row in self._hidden_rows:
return False
# Check the filter text for each row
model = self.sourceModel()
text = self._filter_text.lower()
for column in self._filter_columns:
index = model.index(source_row, column, source_parent)
data = str(model.data(index, QtCore.Qt.DisplayRole) or "")
if self._enable_fuzzy is True:
match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text in data.lower():
return True
return False
class DeviceTableView(BECWidget, QtWidgets.QWidget):
"""Device Table View for the device manager."""
RPC = False
PLUGIN = False
devices_removed = QtCore.Signal(list)
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self.layout = QtWidgets.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
# Setup table view
self._setup_table_view()
# Setup search view, needs table proxy to be iniditate
self._setup_search()
# Add widgets to main layout
self.layout.addLayout(self.search_controls)
self.layout.addWidget(self.table)
def _setup_search(self):
"""Create components related to the search functionality"""
# Create search bar
self.search_layout = QtWidgets.QHBoxLayout()
self.search_label = QtWidgets.QLabel("Search:")
self.search_input = QtWidgets.QLineEdit()
self.search_input.setPlaceholderText(
"Filter devices (approximate matching)..."
) # Default to fuzzy search
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.proxy.setFilterText)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
# Add exact match toggle
self.fuzzy_layout = QtWidgets.QHBoxLayout()
self.fuzzy_label = QtWidgets.QLabel("Exact Match:")
self.fuzzy_is_disabled = QtWidgets.QCheckBox()
self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search)
self.fuzzy_is_disabled.setToolTip(
"Enable approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
self.fuzzy_layout.addWidget(self.fuzzy_label)
self.fuzzy_layout.addWidget(self.fuzzy_is_disabled)
self.fuzzy_layout.addStretch()
# Add both search components to the layout
self.search_controls = QtWidgets.QHBoxLayout()
self.search_controls.addLayout(self.search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(self.fuzzy_layout)
QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
def _setup_table_view(self) -> None:
"""Setup the table view."""
# Model + Proxy
self.table = BECTableView(self)
self.model = DeviceTableModel(parent=self.table)
self.proxy = DeviceFilterProxyModel(parent=self.table)
self.proxy.setSourceModel(self.model)
self.table.setModel(self.proxy)
self.table.setSortingEnabled(True)
# Delegates
self.checkbox_delegate = CenterCheckBoxDelegate(self.table)
self.wrap_delegate = WrappingTextDelegate(self.table)
self.tool_tip_delegate = DictToolTipDelegate(self.table)
self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority
self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled
self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly
self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags
self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description
# Column resize policies
# TODO maybe we need here a flexible header options as deviceClass
# may get quite long for beamlines plugin repos
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly
# TODO maybe better stretch...
header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags
header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description
self.table.setColumnWidth(3, 82)
self.table.setColumnWidth(4, 82)
# Ensure column widths stay fixed
header.setMinimumSectionSize(70)
header.setDefaultSectionSize(90)
# Enable resizing of column
header.sectionResized.connect(self.on_table_resized)
# Selection behavior
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.table.horizontalHeader().setHighlightSections(False)
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
def device_config(self) -> list[dict]:
"""Get the device config."""
return self.model.get_device_config()
def apply_theme(self, theme: str | None = None):
self.checkbox_delegate.apply_theme(theme)
######################################
########### Slot API #################
######################################
@SafeSlot(int, int, int)
def on_table_resized(self, column, old_width, new_width):
"""Handle changes to the table column resizing."""
if column != len(self.model.headers) - 1:
return
for row in range(self.table.model().rowCount()):
index = self.table.model().index(row, column)
delegate = self.table.itemDelegate(index)
option = QtWidgets.QStyleOptionViewItem()
height = delegate.sizeHint(option, index).height()
self.table.setRowHeight(row, height)
######################################
##### Ext. Slot API #################
######################################
@SafeSlot(list)
def set_device_config(self, config: list[dict]):
"""
Set the device config.
Args:
config (list[dict]): The device config to set.
"""
self.model.set_device_config(config)
@SafeSlot()
def clear_device_config(self):
"""
Clear the device config.
"""
self.model.set_device_config([])
@SafeSlot(dict)
def add_device(self, device: dict):
"""
Add a device to the config.
Args:
device (dict): The device to add.
"""
self.model.add_device(device)
@SafeSlot(int)
@SafeSlot(str)
def remove_device(self, dev: int | str):
"""
Remove the device from the config either by row id, or device name.
Args:
dev (int | str): The device to remove, either by row id or device name.
"""
if isinstance(dev, int):
# TODO test this properly, check with proxy index and source index
# Use the proxy model to map to the correct row
model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0))
self.model.remove_device_by_row(model_source_index.row())
return
if isinstance(dev, str):
self.model.remove_device_by_name(dev)
return
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = DeviceTableView()
# pylint: disable=protected-access
config = window.client.device_manager._get_redis_device_config()
window.set_device_config(config)
window.show()
sys.exit(app.exec_())
@@ -0,0 +1,4 @@
"""
This module provides an implementation for the device config view.
The widget is the entry point for users to edit device configurations.
"""
@@ -45,6 +45,7 @@ class ScanControl(BECWidget, QWidget):
Widget to submit new scans to the queue. Widget to submit new scans to the queue.
""" """
USER_ACCESS = ["remove", "screenshot"]
PLUGIN = True PLUGIN = True
ICON_NAME = "tune" ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2 ARG_BOX_POSITION: int = 2
@@ -169,8 +170,8 @@ class ScanControl(BECWidget, QWidget):
self.layout.addWidget(self._metadata_form) self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText()) self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan) self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.metadata_updated.connect(self.update_scan_metadata) self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata) self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form() self._metadata_form.validate_form()
def populate_scans(self): def populate_scans(self):
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
@@ -20,6 +21,8 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = ScanControl(parent) t = ScanControl(parent)
return t return t
@@ -27,7 +30,7 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "Device Control" return "BEC Device Control"
def icon(self): def icon(self):
return designer_material_icon(ScanControl.ICON_NAME) return designer_material_icon(ScanControl.ICON_NAME)
@@ -48,7 +51,7 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ScanControl" return "ScanControl"
def toolTip(self): def toolTip(self):
return "ScanControl" return ""
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -1,4 +1,4 @@
from typing import Literal from typing import Literal, Sequence
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes import material_icon from bec_qthemes import material_icon
@@ -36,7 +36,7 @@ class ScanArgType:
BOOL = "bool" BOOL = "bool"
STR = "str" STR = "str"
DEVICEBASE = "DeviceBase" DEVICEBASE = "DeviceBase"
LITERALS = "dict" LITERALS_DICT = "dict" # Used when the type is provided as a dict with Literal key
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
@@ -83,6 +83,39 @@ class ScanSpinBox(QSpinBox):
self.setValue(default) self.setValue(default)
class ScanLiteralsComboBox(QComboBox):
def __init__(
self, parent=None, arg_name: str | None = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.default = default
if default is not None:
self.setCurrentText(default)
def set_literals(self, literals: Sequence[str | int | float | None]) -> None:
"""
Set the list of literals for the combo box.
Args:
literals: List of literal values (can be strings, integers, floats or None)
"""
self.clear()
literals = set(literals) # Remove duplicates
if None in literals:
literals.remove(None)
self.addItem("")
self.addItems([str(value) for value in literals])
# find index of the default value
index = max(self.findText(str(self.default)), 0)
self.setCurrentIndex(index)
def get_value(self) -> str | None:
return self.currentText() if self.currentText() else None
class ScanDoubleSpinBox(QDoubleSpinBox): class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__( def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
@@ -137,7 +170,7 @@ class ScanGroupBox(QGroupBox):
ScanArgType.INT: ScanSpinBox, ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox, ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit, ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic ScanArgType.LITERALS_DICT: ScanLiteralsComboBox,
} }
device_selected = Signal(str) device_selected = Signal(str)
@@ -226,7 +259,11 @@ class ScanGroupBox(QGroupBox):
for column_index, item in enumerate(group_inputs): for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None) arg_name = item.get("name", None)
default = item.get("default", None) default = item.get("default", None)
widget_class = self.WIDGET_HANDLER.get(item["type"], None) item_type = item.get("type", None)
if isinstance(item_type, dict) and "Literal" in item_type:
widget_class = self.WIDGET_HANDLER.get(ScanArgType.LITERALS_DICT, None)
else:
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
if widget_class is None: if widget_class is None:
logger.error( logger.error(
f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'" f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'"
@@ -239,6 +276,8 @@ class ScanGroupBox(QGroupBox):
widget.set_device_filter(BECDeviceFilter.DEVICE) widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = "" self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected) widget.device_selected.connect(self.emit_device_selected)
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
tooltip = item.get("tooltip", None) tooltip = item.get("tooltip", None)
if tooltip is not None: if tooltip is not None:
widget.setToolTip(item["tooltip"]) widget.setToolTip(item["tooltip"])
@@ -336,6 +375,8 @@ class ScanGroupBox(QGroupBox):
widget = self.layout.itemAtPosition(1, i).widget() widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit) and device_object: if isinstance(widget, DeviceLineEdit) and device_object:
value = widget.get_current_device().name value = widget.get_current_device().name
elif isinstance(widget, ScanLiteralsComboBox):
value = widget.get_value()
else: else:
value = WidgetIO.get_value(widget) value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value kwargs[widget.arg_name] = value
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
@@ -20,6 +21,8 @@ class DapComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = DapComboBox(parent) t = DapComboBox(parent)
return t return t
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
@@ -20,6 +21,8 @@ class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = LMFitDialog(parent) t = LMFitDialog(parent)
return t return t
@@ -48,7 +51,7 @@ class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LMFitDialog" return "LMFitDialog"
def toolTip(self): def toolTip(self):
return "LMFitDialog" return "Dialog for displaying the fit summary and params for LMFit DAP processes"
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -0,0 +1,244 @@
from typing import Literal
import qtmonaco
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_name
class MonacoWidget(BECWidget, QWidget):
"""
A simple Monaco editor widget
"""
text_changed = Signal(str)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
"set_text",
"get_text",
"insert_text",
"delete_line",
"set_language",
"get_language",
"set_theme",
"get_theme",
"set_readonly",
"set_cursor",
"current_cursor",
"set_minimap_enabled",
"set_vim_mode_enabled",
"set_lsp_header",
"get_lsp_header",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.editor = qtmonaco.Monaco(self)
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.initialized.connect(self.apply_theme)
def apply_theme(self, theme: str | None = None) -> None:
"""
Apply the current theme to the Monaco editor.
Args:
theme (str, optional): The theme to apply. If None, the current theme will be used.
"""
if theme is None:
theme = get_theme_name()
editor_theme = "vs" if theme == "light" else "vs-dark"
self.set_theme(editor_theme)
def set_text(self, text: str) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
"""
self.editor.set_text(text)
def get_text(self) -> str:
"""
Get the current text from the Monaco editor.
"""
return self.editor.get_text()
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
self.editor.insert_text(text, line, column)
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
self.editor.delete_line(line)
def set_cursor(
self,
line: int,
column: int = 1,
move_to_position: Literal[None, "center", "top", "position"] = None,
) -> None:
"""
Set the cursor position in the Monaco editor.
Args:
line (int): Line number (1-based).
column (int): Column number (1-based), defaults to 1.
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
"""
self.editor.set_cursor(line, column, move_to_position)
def current_cursor(self) -> dict[str, int]:
"""
Get the current cursor position in the Monaco editor.
Returns:
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
"""
return self.editor.current_cursor
def set_language(self, language: str) -> None:
"""
Set the programming language for syntax highlighting in the Monaco editor.
Args:
language (str): The programming language to set (e.g., "python", "javascript").
"""
self.editor.set_language(language)
def get_language(self) -> str:
"""
Get the current programming language set in the Monaco editor.
"""
return self.editor.get_language()
def set_readonly(self, read_only: bool) -> None:
"""
Set the Monaco editor to read-only mode.
Args:
read_only (bool): If True, the editor will be read-only.
"""
self.editor.set_readonly(read_only)
def set_theme(self, theme: str) -> None:
"""
Set the theme for the Monaco editor.
Args:
theme (str): The theme to set (e.g., "vs-dark", "light").
"""
self.editor.set_theme(theme)
def get_theme(self) -> str:
"""
Get the current theme of the Monaco editor.
"""
return self.editor.get_theme()
def set_minimap_enabled(self, enabled: bool) -> None:
"""
Enable or disable the minimap in the Monaco editor.
Args:
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
self.editor.set_minimap_enabled(enabled)
def set_highlighted_lines(self, start_line: int, end_line: int) -> None:
"""
Highlight a range of lines in the Monaco editor.
Args:
start_line (int): The starting line number (1-based).
end_line (int): The ending line number (1-based).
"""
self.editor.set_highlighted_lines(start_line, end_line)
def clear_highlighted_lines(self) -> None:
"""
Clear any highlighted lines in the Monaco editor.
"""
self.editor.clear_highlighted_lines()
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
self.editor.set_vim_mode_enabled(enabled)
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
self.editor.set_lsp_header(header)
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
return self.editor.get_lsp_header()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])
widget = MonacoWidget()
# set the default size
widget.resize(800, 600)
widget.set_language("python")
widget.set_theme("vs-dark")
widget.editor.set_minimap_enabled(False)
widget.set_text(
"""
import numpy as np
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bec_lib.devicemanager import DeviceContainer
from bec_lib.scans import Scans
dev: DeviceContainer
scans: Scans
#######################################
########## User Script #####################
#######################################
# This is a comment
def hello_world():
print("Hello, world!")
"""
)
widget.set_highlighted_lines(1, 3)
widget.show()
qapp.exec_()
@@ -0,0 +1 @@
{'files': ['monaco_widget.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
DOM_XML = """
<ui language='c++'>
<widget class='MonacoWidget' name='monaco_widget'>
</widget>
</ui>
"""
class MonacoWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = MonacoWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Developer"
def icon(self):
return designer_material_icon(MonacoWidget.ICON_NAME)
def includeFile(self):
return "monaco_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "MonacoWidget"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.monaco.monaco_widget_plugin import MonacoWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MonacoWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor import SBBMonitor from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor import SBBMonitor
@@ -20,6 +21,8 @@ class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = SBBMonitor(parent) t = SBBMonitor(parent)
return t return t
@@ -27,7 +30,7 @@ class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "" return "BEC Utils"
def icon(self): def icon(self):
return designer_material_icon(SBBMonitor.ICON_NAME) return designer_material_icon(SBBMonitor.ICON_NAME)
@@ -85,7 +85,6 @@ class ScanMetadata(PydanticModelForm):
def set_schema_from_scan(self, scan_name: str | None): def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or "" self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name)) self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
@@ -20,6 +21,8 @@ class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = ScanMetadata(parent) t = ScanMetadata(parent)
return t return t
@@ -27,7 +30,7 @@ class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML return DOM_XML
def group(self): def group(self):
return "" return "BEC Input Widgets"
def icon(self): def icon(self):
return designer_material_icon(ScanMetadata.ICON_NAME) return designer_material_icon(ScanMetadata.ICON_NAME)
@@ -48,7 +51,7 @@ class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ScanMetadata" return "ScanMetadata"
def toolTip(self): def toolTip(self):
return "Dynamically generates a form for inclusion of metadata for a scan." return "ScanMetadata"
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -1,10 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.text_box.text_box import TextBox from bec_widgets.widgets.editors.text_box.text_box import TextBox
@@ -14,7 +13,6 @@ DOM_XML = """
</widget> </widget>
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,6 +21,8 @@ class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = TextBox(parent) t = TextBox(parent)
return t return t
@@ -51,7 +51,7 @@ class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "TextBox" return "TextBox"
def toolTip(self): def toolTip(self):
return "TextBox" return "A widget that displays text in plain and HTML format"
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
@@ -1,10 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd. # Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
@@ -15,8 +14,6 @@ DOM_XML = """
</ui> </ui>
""" """
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self): def __init__(self):
@@ -24,6 +21,8 @@ class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None self._form_editor = None
def createWidget(self, parent): def createWidget(self, parent):
if parent is None:
return QWidget()
t = VSCodeEditor(parent) t = VSCodeEditor(parent)
return t return t

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