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

Compare commits

..

227 Commits

Author SHA1 Message Date
a275c5a3b1 wip 2026-03-15 14:31:39 +01:00
b12e6432d6 wip 2026-03-15 13:59:43 +01:00
b802beec69 wip 2026-03-15 13:53:37 +01:00
6c40df4380 wip 2026-03-15 13:36:13 +01:00
dc8ae5a85d wip 2026-03-15 13:31:45 +01:00
2d41a158bc wip 2026-03-15 13:25:42 +01:00
c2a6964875 wip 2026-03-15 13:18:15 +01:00
774ca08cb3 wip 2026-03-15 13:06:18 +01:00
4606bbd570 wip 2026-03-15 13:01:44 +01:00
b2ed9d42f7 wip 2026-03-15 12:57:19 +01:00
d799691e12 wip 2026-03-15 12:47:58 +01:00
6848a9e20b test(e2e): avoid timing issues in rpc_gui_obj test 2026-03-15 12:30:40 +01:00
semantic-release
bd5aafc052 3.2.0
Automatically generated by python-semantic-release
2026-03-11 20:52:57 +00:00
b4f6f5aa8b feat(waveform): composite DAP with multiple models 2026-03-11 21:52:10 +01:00
14d51b8016 feat(curve, waveform): add dap_parameters for lmfit customization in DAP requests 2026-03-11 21:52:10 +01:00
semantic-release
e94554b471 3.1.4
Automatically generated by python-semantic-release
2026-03-11 11:58:34 +00:00
7e0e391888 build: increased minimal version of bec and bec qthemes 2026-03-11 12:57:40 +01:00
53e5ec42b8 fix(profile_utils): renamed to fetch widgets settings 2026-03-11 12:57:40 +01:00
semantic-release
0e49828a23 3.1.3
Automatically generated by python-semantic-release
2026-03-09 08:46:29 +00:00
278d8de058 fix(monaco_dock): optimization, removal of QTimer, eventFilter replaced by signal/slot 2026-03-09 09:45:40 +01:00
semantic-release
cb4c2beed4 3.1.2
Automatically generated by python-semantic-release
2026-03-06 15:34:15 +00:00
4382d5c9b1 fix(dock_area): remove old AdvancedDockArea references 2026-03-06 16:33:23 +01:00
8463b32792 build(deps): update isort requirement 2026-03-06 12:02:48 +01:00
semantic-release
5aff336446 3.1.1
Automatically generated by python-semantic-release
2026-03-06 10:47:17 +00:00
e2daf2e89c build: update min bec dependency to 3.106 2026-03-06 11:46:29 +01:00
ef1233163c test: fix import of bec_lib json extended 2026-03-06 11:46:29 +01:00
419c01bdd4 fix(positioner box): include username in scan queue request 2026-03-06 11:46:29 +01:00
d4e037f338 refactor(black): black 26 applied 2026-03-06 11:25:37 +01:00
e157f0d7c9 build(deps): upgrade to black 26 2026-03-06 11:25:37 +01:00
semantic-release
2136699806 3.1.0
Automatically generated by python-semantic-release
2026-03-06 10:00:11 +00:00
75697f5b1f test: adjust metadata assertions to new schema defaults 2026-03-06 10:59:24 +01:00
2697496515 test: adjust metadata assertions to new schema defaults 2026-03-06 10:59:24 +01:00
56f16b6352 feat(bec_queue): add tooltip support for user metadata in queue display 2026-03-06 10:59:24 +01:00
ab3efdbd0a feat(scan queue): add scan name to queue 2026-03-06 10:59:24 +01:00
0b94ee1485 tests: fix tests to match bec core changes 2026-03-06 10:59:24 +01:00
72e66cf57f fix(scan metadata): set scan_name to current scan if empty in form data 2026-03-06 10:59:24 +01:00
e6b41b4e92 feat(scan control): wrap metadata form in a group box for better organization 2026-03-06 10:59:24 +01:00
ac824f6b83 feat(StrFormItem): set placeholder text from spec description 2026-03-06 10:59:24 +01:00
5e34c8a351 fix(forms): use FieldInfo title for label text in _add_griditem method 2026-03-06 10:59:24 +01:00
semantic-release
29ff45a24b 3.0.0
Automatically generated by python-semantic-release
2026-03-06 08:57:30 +00:00
bfc9f19472 fix(main_app): temporarily disable IDE view
Will remain disabled until we have migrated the IDE view to use the procedures
2026-03-06 09:13:29 +01:00
5bcf440be7 fix(main_app): rpc access refined 2026-03-05 18:12:33 +01:00
758956be09 feat(generate_cli): RPC API from content widget can be merged with the RPC API of the container widget statically 2026-03-05 18:12:33 +01:00
010373fd5b feat(becconnector): added rpc_passthrough_children flag in addition to rpc_exposed 2026-03-05 18:12:33 +01:00
0eabd0f72b fix(rpc_server): add check for rpc_exposed to serialize_object 2026-03-05 18:12:33 +01:00
963941a788 fix(dock_area): profile management with empty profile, applied across the whole repo 2026-03-05 18:12:33 +01:00
f8be43741a fix(main_window): safeguard of fetching the launcher from the main window if GUIServer is not running 2026-03-05 18:12:33 +01:00
4d41be61b5 fix(client_utils): safeguard for accessing gui.new and launcher if GUIServer not running 2026-03-05 18:12:33 +01:00
04b448e183 fix(main_window): scan progress bar rpc not exposed 2026-03-05 18:12:33 +01:00
654aeb7116 refactor(main_app): simpler id and object name management 2026-03-05 18:12:33 +01:00
3d049d67a9 fix(view): based on BECWidgets 2026-03-05 18:12:33 +01:00
de6c6284ad feat(becconnector): exposed rpc flag added to the BECConnector 2026-03-05 18:12:33 +01:00
2826919c5a refactor: add extra tour steps, add enter button 2026-03-05 18:12:33 +01:00
a1a400f540 fix: address copilot review 2026-03-05 18:12:33 +01:00
fcb43066e4 feat: add guided tour docs to device-manager-view 2026-03-05 18:12:33 +01:00
83489b7519 refactor(guided-tour): add support for QTableWidgetItem 2026-03-05 18:12:33 +01:00
7bcdc31f11 fix(guided-tour): fix skip past invalid step for 'prev' step 2026-03-05 18:12:33 +01:00
3ffdf11c3e feat: guided tour for main app 2026-03-05 18:12:33 +01:00
0d05839e9e fix(server): gui server can reach shutdown, logic moved to becconnector 2026-03-05 18:12:33 +01:00
a632f35c40 fix(dock_area): tabbed dock have correct parent 2026-03-05 18:12:33 +01:00
3a5317be53 fix(dock_area): widget_map and widget_list by default returns only becconnector based widgets 2026-03-05 18:12:32 +01:00
copilot-swe-agent[bot]
041afc68b1 test(widget_io): add dedicated unit tests for iter_widget_tree and helper methods
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
2026-03-05 18:12:32 +01:00
047ff2bef7 fix(rpc_server): removed unused get _get_becwidget_ancestor 2026-03-05 18:12:32 +01:00
5f46fa0994 feat(widget_hierarchy_tree): widget displaying parent child hierarchy from the app widgets 2026-03-05 18:12:32 +01:00
8b782ac302 feat(widget_highlighter): reusable separate widget highlighter 2026-03-05 18:12:32 +01:00
00bf01c129 refactor(widget_io): hierarchy logic generalized 2026-03-05 18:12:32 +01:00
dependabot[bot]
4a44ede8fe build(deps): update bec-qthemes requirement
Updates the requirements on [bec-qthemes](https://github.com/bec-project/bec_qthemes) to permit the latest version.
- [Release notes](https://github.com/bec-project/bec_qthemes/releases)
- [Changelog](https://github.com/bec-project/bec_qthemes/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bec-project/bec_qthemes/compare/v0.7.0...v1.3.3)

---
updated-dependencies:
- dependency-name: bec-qthemes
  dependency-version: 1.3.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-05 18:12:32 +01:00
b93fbc5cd3 refactor: global refactoring to use device-signal pair names 2026-03-05 18:12:32 +01:00
c1d4758e4c feat(client_utils): theme can be changed from the CLI 2026-03-05 18:12:32 +01:00
4fc252220d fix(dark_mode_button): RPC access disabled 2026-03-05 18:12:32 +01:00
375d131109 fix(toggle): move toggle to theme colors 2026-03-05 18:12:32 +01:00
5b15c75b88 fix(rpc): rpc flags adjustment for MainApp and DeveloperWidget 2026-03-05 18:12:32 +01:00
b29648e10b fix(cli): RPC API from any folder 2026-03-05 18:12:32 +01:00
7507f27d68 fix(becconnector): sanitize the setObjectName from qobject inheritance 2026-03-05 18:12:32 +01:00
b798ea2340 feat(bec-login): Add login widget in material design style 2026-03-05 18:12:32 +01:00
ab9688d2b5 fix(main_app): the dock area view implemented as a viewBase 2026-03-05 18:12:32 +01:00
92ae5fc7fb fix: removal of old BECDock import 2026-03-05 18:12:32 +01:00
cd9c7ab079 fix(colors): more benevolent fetching of colormap names, avoid hardcoded wrong colormap mapping from GradientWidget from pg 2026-03-05 18:12:32 +01:00
fb55e72713 fix(screen_utils): screen utilities added and fixed sizing for widgets from launch window and main app 2026-03-05 18:12:32 +01:00
71ed2d353a refactor(dock_area): change name to BECDockArea 2026-03-05 18:12:32 +01:00
338ff455cc fix(ophyd-validation): add device_manager_ds argument if available for ophyd validation 2026-03-05 18:12:32 +01:00
48387c0ad9 fix(editors): VSCode widget removed 2026-03-05 18:12:32 +01:00
37298c21c3 ci: cancel previous CI run for PR or branch 2026-03-05 18:12:32 +01:00
80c0dfa4f2 feat(image): modernization of image widget 2026-03-05 18:12:32 +01:00
6c73307bb4 fix(device_combobox): public flag for valid input 2026-03-05 18:12:32 +01:00
940face118 fix(device_input_widgets): removed RPC access 2026-03-05 18:12:32 +01:00
fbddf4a284 feat(device_combobox): device filter added based on its signal classes 2026-03-05 18:12:32 +01:00
cfd6bde268 feat(signal_combobox): extended that can filter by signal class and dimension of the signal 2026-03-05 18:12:32 +01:00
dd69578b91 feat(plot_base): plot_base, image and heatmap widget adopted to property-toolbar sync 2026-03-05 18:12:32 +01:00
4357d984c8 feat(toolbar): toolbar can be synced with the property_changed for toggle actions 2026-03-05 18:12:32 +01:00
7cce3bd542 feat(SafeProperty): SafeProperty emits property_changed signal 2026-03-05 18:12:32 +01:00
3f76ade628 test(e2e): raise with widget name 2026-03-05 18:12:32 +01:00
84d6653d19 fix(rpc_server): use single shot instead of processEvents to avoid dead locks 2026-03-05 18:12:32 +01:00
7fd7f67857 fix: adjust ring progress bar to ads 2026-03-05 18:12:32 +01:00
2992939b0f fix(FakeDevice): add _info dict 2026-03-05 18:12:32 +01:00
a84b924162 feat(color): add relative luminance calculation 2026-03-05 18:12:32 +01:00
5435fec68a feat: add export and load settings methods to BECConnector; add SafeProperty safe getter flag 2026-03-05 18:12:32 +01:00
38eb2441cd fix(rpc_register): listing only valid connections 2026-03-05 18:12:32 +01:00
d9b728584f fix(launch_window): logic for showing launcher 2026-03-05 18:12:32 +01:00
947bf63e03 fix(main_window): parent fixed for notification broker 2026-03-05 18:12:32 +01:00
3fe6a00708 fix(device-progress-bar): remove stretch in content layout 2026-03-05 18:12:32 +01:00
793779db68 style: wrap progress bar in widget to fix background 2026-03-05 18:12:32 +01:00
de835e81d8 test: fix test 2026-03-05 18:12:32 +01:00
fa56fc8802 fix: tooltip logic and disable button on running scan 2026-03-05 18:12:32 +01:00
c1443fa27a feat: attach config cancellation to closeEvent 2026-03-05 18:12:31 +01:00
8bbd519559 fix: remove manual stylesheet deletion/override 2026-03-05 18:12:31 +01:00
9c4a54493a fix: 'Any' type annotations 2026-03-05 18:12:31 +01:00
a9f92cf155 fix(_OverlayEventFilter): fix typo 2026-03-05 18:12:31 +01:00
12b4d3a9e0 refactor(device-form-dialog): Use native QDialogButtonBox instead of GroupBox layout 2026-03-05 18:12:31 +01:00
229da6244a refactor(busy-loager): Improve eventFilter to avoid crashs if target or overlay is None. 2026-03-05 18:12:31 +01:00
4edc57158b test(device-manager-view): improve test coverage for device-manager-view 2026-03-05 18:12:31 +01:00
72639e7e5f fix(signal-label): Fix signal label cleanup, missing parent in constructors 2026-03-05 18:12:31 +01:00
b67f3a14aa test cleanup add mocked client 2026-03-05 18:12:31 +01:00
fa49322d1f fix(device-manager-display-widget): fix error message popup on cancelling upload 2026-03-05 18:12:31 +01:00
24701c2a27 test(config-communicator): add test for cancel action 2026-03-05 18:12:31 +01:00
caba3a55f3 fix(device-init-progress-bar): fix ui format for device init progressbar 2026-03-05 18:12:31 +01:00
94faaba24d fix(busy-loader): adjust busy loader and tests 2026-03-05 18:12:31 +01:00
332ca205c1 refactor(busy-loader): refactor busy loader to use custom widget 2026-03-05 18:12:31 +01:00
5deafb9797 feat(device-initialization-progress-bar): add progress bar for device initialization 2026-03-05 18:12:31 +01:00
1f363d9bd4 fix(colors): added logger to the apply theme 2026-03-05 18:12:31 +01:00
c61d00e761 fix(launch_window): processEvents removed 2026-03-05 18:12:31 +01:00
3236dfb07f fix(advanced_dock_area): removed the singleShot for load_initial_profile 2026-03-05 18:12:31 +01:00
853f16ad3a fix(view):removed splitter logic 2026-03-05 18:12:31 +01:00
6cff8d7a41 fix(basic_dock_area): removed the singleShot usage 2026-03-05 18:12:31 +01:00
a56bd572a0 fix(widgets): processEvent removed from widgets using it 2026-03-05 18:12:31 +01:00
e26a90c62f fix: remove singleShots from BECConnector and adjustments of dock area logic 2026-03-05 18:12:31 +01:00
6113debc6c fix(positioner_box): layout HV centered and size taken from the ui file 2026-03-05 18:12:31 +01:00
56b1e6687f fix(bec_connector): use RPC register to fetch all connections 2026-03-05 18:12:31 +01:00
168bb3cb77 feat(motor_map): motor selection adopted to splitter action 2026-03-05 18:12:31 +01:00
0752f3d6a9 feat(toolbar): splitter action added 2026-03-05 18:12:31 +01:00
08e19858ea fix(scatter waveform): fix tab order for settings panel 2026-03-05 18:12:31 +01:00
f6712e8bb8 fix(scatter_waveform): remove curve_json from the properties 2026-03-05 18:12:31 +01:00
66a95102dd fix(signal_combo_box): get_signal_name added; remove duplicates from heatmap and scatter waveform settings; 2026-03-05 18:12:31 +01:00
dea73a97c9 fix(scatter_waveform): modernization of scatter waveform settings dialog 2026-03-05 18:12:31 +01:00
7ab8e0c2ed fix(scatter_waveform): devices and entries saved as properties 2026-03-05 18:12:31 +01:00
6baf1962fa fix(heatmap): devices are saved as SafeProperties 2026-03-05 18:12:31 +01:00
6296055c66 test(script_tree): improve hover event handling with waitUntil 2026-03-05 18:12:31 +01:00
5e111cfc54 feat(web console): add support for shared web console sessions 2026-03-05 18:12:31 +01:00
836fedd50e test(device-manager): use mocked client for tests 2026-03-05 18:12:31 +01:00
89d5c5abdb refactor(ophyd-validation): Allow option to keep device visible after successful validation 2026-03-05 18:12:31 +01:00
1d654bd8bd fix(device-manager): fix minor icon synchronization bugs 2026-03-05 18:12:31 +01:00
7805c7a191 fix(device-manager-display-widget): Remove devices from ophyd validation after upload to BEC 2026-03-05 18:12:31 +01:00
f827e77e87 test(device-form-dialog): adapt tests 2026-03-05 18:12:31 +01:00
36be5292da fix(device-form-dialog): Adapt device-form-dialog ophyd validation test 2026-03-05 18:12:31 +01:00
7c28364594 fix(device-form-dialog): Adapt DeviceFormDialog to run validation of config upon editing/adding a config, and forward validation results 2026-03-05 18:12:31 +01:00
b43b6e844b fix(CLI): change the default behavior of launching the profiles in CLI 2026-03-05 18:12:31 +01:00
eda30e3139 refactor(advanced_dock_area): change remove_widget to delete 2026-03-05 18:12:31 +01:00
aba67d3129 fix(advanced_dock_area): empty profile is always empty 2026-03-05 18:12:31 +01:00
6883982bf6 fix(advanced_dock_area): CLI API adjustments docs + names 2026-03-05 18:12:31 +01:00
013b916ca3 fix(advanced_dock_area): replace sanitize_namespace with slugify 2026-03-05 18:12:31 +01:00
be55bf20c1 fix(client_utils): delete is deleting window and its content 2026-03-05 18:12:31 +01:00
9c66dd5991 fix(CLI): dock_area can be created from CLI with specific profile or empty 2026-03-05 18:12:31 +01:00
8f44213ecc fix(advanced_dock_area): remove widget from dock area by object name 2026-03-05 18:12:31 +01:00
22df7bb532 fix(advanced_dock_area): profile behaviour adjusted, cleanup of the codebase 2026-03-05 18:12:31 +01:00
beca23e14e fix: sanitize name space util for bec connector and ads 2026-03-05 18:12:31 +01:00
06745e0511 fix(main_app): dock area from main app shares the workspace name with the CLI one to reuse the profiles created in the cli companion window 2026-03-05 18:12:30 +01:00
6459281387 fix(launch_window): launch geometry for widgets launched from launcher to 80% of the primary screen as default 2026-03-05 18:12:30 +01:00
3c16909a87 fix(launch_window): argument to start with the gui class 2026-03-05 18:12:30 +01:00
a6583ad53f fix(dock_area): the old BECDockArea(pg) removed and replaces by AdvancedDockArea(ADS) 2026-03-05 18:12:30 +01:00
88b6e015bf fix(advanced_dock_area): removed non-functional dock_list and dock_map from RPC 2026-03-05 18:12:30 +01:00
0d6b94aaec fix(advanced_dock_area): new profiles are saved with quickselect as default 2026-03-05 18:12:30 +01:00
7d2760eab8 fix(advanced_dock_area): ensure the general profile exists when launched first time 2026-03-05 18:12:30 +01:00
b841cfbc5f fix(advanced_dock_area): remove all widgets when loading new profiles 2026-03-05 18:12:30 +01:00
6b2b42f21a fix(basic_dock_area): delete_all will also delete floating docks 2026-03-05 18:12:30 +01:00
522934f8cd fix(main_window): delete on close 2026-03-05 18:12:30 +01:00
96a52a0cb0 fix(main_app): center the application window on the screen 2026-03-05 18:12:30 +01:00
7ccfcc9f52 fix(main_app): refactor main function and update script entry point in pyproject.toml 2026-03-05 18:12:30 +01:00
c9a8e64217 ci: use shared issue sync action instead of local version 2026-03-05 18:12:30 +01:00
d085f65153 fix(developer widget): save before executing a scripts 2026-03-05 18:12:30 +01:00
c53d4c0ad7 fix(monaco widget): reset current_file 2026-03-05 18:12:30 +01:00
3631fc2649 fix(monaco dock): update last focused editor when closing 2026-03-05 18:12:30 +01:00
31364772bd fix(monaco_dock): update editor metadata handling and improve open_file method 2026-03-05 18:12:30 +01:00
5e0c376774 refactor(developer_widget): enhance documentation and add missing imports 2026-03-05 18:12:30 +01:00
fa79179f89 feat(developer_widget): add signal connection for focused editor changes to disable run button for macro files 2026-03-05 18:12:30 +01:00
7083f94f46 fix(client): client API regenerated 2026-03-05 18:12:30 +01:00
a6357af8ff feat(device-manager): Add DeviceManager Widget for BEC Widget main applications 2026-03-05 18:12:30 +01:00
3ebac55e2d fix(general_app): old general app example removed 2026-03-05 18:12:30 +01:00
6fc524c819 fix(heatmap): interpolation thread is killed only on exit, logger for dandling thread 2026-03-05 18:12:30 +01:00
f98a5de7e9 perf(heatmap): thread worker optimization 2026-03-05 18:12:30 +01:00
323c8d5bc0 fix(heatmap): interpolation of the image moved to separate thread 2026-03-05 18:12:30 +01:00
96060fca53 fix(motor_map): x/y motor are saved in properties 2026-03-05 18:12:30 +01:00
c1d0e435d5 fix: don't wait forever 2026-03-05 18:12:30 +01:00
84c7360bb8 fix(widget_state_manager): PROPERTIES_TO_SKIP are not restored even if in ini file 2026-03-05 18:12:30 +01:00
440cecddf7 feat(advanced_dock_area): floating docks restore with relative geometry 2026-03-05 18:12:30 +01:00
19b7310433 refactor: improvements to enum access 2026-03-05 18:12:30 +01:00
bcaf013d2b feat(advanced_dock_area): instance lock for multiple ads in same session 2026-03-05 18:12:30 +01:00
b72bf4a0f9 fix(widgets): removed isVisible from all SafeProperties 2026-03-05 18:12:30 +01:00
f38cd3e3a0 fix(bec_widget): improved qt enums; grab safeguard 2026-03-05 18:12:30 +01:00
2f9d6d59ee fix(qt_ads): pythons stubs match structure of PySide6QtAds 2026-03-05 18:12:30 +01:00
7ea4352a09 fix(widget_state_manager): filtering of not wanted properties 2026-03-05 18:12:30 +01:00
ac850ec650 refactor(main_app): adapted for DockAreaWidget changes 2026-03-05 18:12:30 +01:00
4d40918b7c refactor(developer_view): changed to use DockAreaWidget 2026-03-05 18:12:30 +01:00
ed0d34a60f refactor(monaco_dock): changed to use DockAreaWidget 2026-03-05 18:12:30 +01:00
58b88efcb6 feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants 2026-03-05 18:12:30 +01:00
cab422777c fix(main_window): removed general forced cleanup 2026-03-05 18:12:30 +01:00
7305498475 feat(advanced_dock_area): UI/UX for profile management improved, saving directories logic adjusted 2026-03-05 18:12:30 +01:00
06cb187d1a fix(main_window): cleanup adjusted with shiboken6 2026-03-05 18:12:30 +01:00
1c18810e5f fix(dark_mode_button): skip settings added 2026-03-05 18:12:30 +01:00
338b9e1aa7 fix(widget_state_manager): added shiboken check 2026-03-05 18:12:30 +01:00
ed2651a914 feat(bec_widget): save screenshot to bytes 2026-03-05 18:12:30 +01:00
0b9e5c15af fix(becconnector): ophyd thread killer on exit + in conftest 2026-03-05 18:12:30 +01:00
9b753c1f24 feat(guided_tour): add guided tour 2026-03-05 18:12:30 +01:00
17e678b0ad fix: add metadata to scan control export 2026-03-05 18:12:30 +01:00
bdef594b58 feat(developer_view): add developer view 2026-03-05 18:12:29 +01:00
66f3e517f0 feat(jupyter_console_window): adjustment for general usage 2026-03-05 18:12:29 +01:00
4c4fc25a42 feat(ads): add pyi stub file to provide type hints for ads 2026-03-05 18:12:29 +01:00
9e4be38c0b feat(dm-view): initial device manager view added 2026-03-05 18:12:29 +01:00
5ac629de8c feat(help-inspector): add help inspector widget 2026-03-05 18:12:29 +01:00
90ba505c10 fix(signal_label): dispatcher unsubscribed in the cleanup 2026-03-05 18:12:29 +01:00
c923f79293 fix(client): abort, reset, stop button removed from RPC access 2026-03-05 18:12:29 +01:00
b30e1e4c5e feat(main_app): main app with interactive app switcher 2026-03-05 18:12:29 +01:00
9c3a6e1691 feat(actions): actions can be created with label text with beside or under alignment 2026-03-05 18:12:29 +01:00
92c15a7f82 feat(busy_loader): busy loader added to bec widget base class 2026-03-05 18:12:29 +01:00
4b5a45c320 feat: add SafeConnect 2026-03-05 18:12:29 +01:00
b0cd619d7d fix(bec_widgets): adapt to bec_qthemes 1.0; themes can be only applied on living Qt objects 2026-03-05 18:12:29 +01:00
d25314e6ee feat(advanced_dock_area): added ads based dock area with profiles 2026-03-05 18:12:29 +01:00
55c8a57e71 fix(web_console): added startup kwarg 2026-03-05 18:12:29 +01:00
37bfad7174 refactor(bec_main_window): main app theme renamed to View 2026-03-05 18:12:29 +01:00
98e29792a2 fix(widget_state_manager): state manager can save all properties recursively to already existing settings 2026-03-05 18:12:29 +01:00
82dbf31da5 feat(bec_widget): attach/detach method for all widgets + client regenerated 2026-03-05 18:12:29 +01:00
db83576346 feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2026-03-05 18:12:29 +01:00
389a93f8d0 fix(bec_connector): widget_removed and name_established signals added 2026-03-05 18:12:29 +01:00
b6d70c34df ci: install ttyd 2026-03-05 18:12:29 +01:00
d301fdfeb2 ci: add artifact upload 2026-03-05 18:12:29 +01:00
562001c08c build!: PySide6-QtAds; bec_qtheme V1; dependencies updated and adjusted 2026-03-05 18:12:28 +01:00
semantic-release
ade65dd629 2.45.14
Automatically generated by python-semantic-release
2026-01-23 16:50:26 +00:00
709ffd6927 fix(bec_status): adjust bec status widget to info and version signature 2026-01-23 17:49:41 +01:00
138 changed files with 6356 additions and 3098 deletions

View File

@@ -1,80 +1,82 @@
name: Full CI
on:
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
type: string
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: write
jobs:
check_pr_status:
uses: ./.github/workflows/check_pr.yml
# check_pr_status:
# uses: ./.github/workflows/check_pr.yml
formatter:
needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
# formatter:
# needs: check_pr_status
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/formatter.yml
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# unit-test:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/pytest.yml
# with:
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
# secrets:
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test-matrix:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
# unit-test-matrix:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/pytest-matrix.yml
# with:
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
generate-cli-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml
# generate-cli-test:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/generate-cli-check.yml
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml
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 }}
# 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 }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
# 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 }}

View File

@@ -48,12 +48,14 @@ jobs:
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
pip install pytest-repeat
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end/user_interaction/test_user_interaction_e2e.py
- name: Upload logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
path: ./bec/logs/*.log
retention-days: 7

View File

@@ -1,6 +1,723 @@
# CHANGELOG
## v3.2.0 (2026-03-11)
### Features
- **curve, waveform**: Add dap_parameters for lmfit customization in DAP requests
([`14d51b8`](https://github.com/bec-project/bec_widgets/commit/14d51b80169f5a060dd24287f3a6db9a4b41275a))
- **waveform**: Composite DAP with multiple models
([`b4f6f5a`](https://github.com/bec-project/bec_widgets/commit/b4f6f5aa8bcd0f6091610e8f839ea265c87575e0))
## v3.1.4 (2026-03-11)
### Bug Fixes
- **profile_utils**: Renamed to fetch widgets settings
([`53e5ec4`](https://github.com/bec-project/bec_widgets/commit/53e5ec42b8b33397af777f418fbd8601628226a6))
### Build System
- Increased minimal version of bec and bec qthemes
([`7e0e391`](https://github.com/bec-project/bec_widgets/commit/7e0e391888f2ee4e8528ccb3938e36da4c32f146))
## v3.1.3 (2026-03-09)
### Bug Fixes
- **monaco_dock**: Optimization, removal of QTimer, eventFilter replaced by signal/slot
([`278d8de`](https://github.com/bec-project/bec_widgets/commit/278d8de058c2f5c6c9aa7317e1026651d7a4acd3))
## v3.1.2 (2026-03-06)
### Bug Fixes
- **dock_area**: Remove old AdvancedDockArea references
([`4382d5c`](https://github.com/bec-project/bec_widgets/commit/4382d5c9b1fdac4048692eec53dd43127d67467b))
### Build System
- **deps**: Update isort requirement
([`8463b32`](https://github.com/bec-project/bec_widgets/commit/8463b327923f853cfa1462bc22be1e83d4fd9a75))
## v3.1.1 (2026-03-06)
### Bug Fixes
- **positioner box**: Include username in scan queue request
([`419c01b`](https://github.com/bec-project/bec_widgets/commit/419c01bdd4e80d927761634b03723319b0a58694))
### Build System
- Update min bec dependency to 3.106
([`e2daf2e`](https://github.com/bec-project/bec_widgets/commit/e2daf2e89cd25d4dcedd4895299dbbdc6b7e354f))
- **deps**: Upgrade to black 26
([`e157f0d`](https://github.com/bec-project/bec_widgets/commit/e157f0d7c9bb5b4d93f63ebe6f9a715a314aa1f4))
### Refactoring
- **black**: Black 26 applied
([`d4e037f`](https://github.com/bec-project/bec_widgets/commit/d4e037f3384765e7bb8fb020cecbf3db24fc7494))
### Testing
- Fix import of bec_lib json extended
([`ef12331`](https://github.com/bec-project/bec_widgets/commit/ef1233163cb7c3229630543fe88dbceaccd09297))
## v3.1.0 (2026-03-06)
### Bug Fixes
- **forms**: Use FieldInfo title for label text in _add_griditem method
([`5e34c8a`](https://github.com/bec-project/bec_widgets/commit/5e34c8a3518f24722267b3cde2dd9d3494e350b0))
- **scan metadata**: Set scan_name to current scan if empty in form data
([`72e66cf`](https://github.com/bec-project/bec_widgets/commit/72e66cf57f1d47851728448e2e0f776cd8e278f2))
### Features
- **bec_queue**: Add tooltip support for user metadata in queue display
([`56f16b6`](https://github.com/bec-project/bec_widgets/commit/56f16b63528b5a50f5a2e2d2e9dd93f3993e50e4))
- **scan control**: Wrap metadata form in a group box for better organization
([`e6b41b4`](https://github.com/bec-project/bec_widgets/commit/e6b41b4e92a1ffd0494c2bde6a782347c2364114))
- **scan queue**: Add scan name to queue
([`ab3efdb`](https://github.com/bec-project/bec_widgets/commit/ab3efdbd0a0a80293ba2121e78ea319ddbbd8f82))
- **StrFormItem**: Set placeholder text from spec description
([`ac824f6`](https://github.com/bec-project/bec_widgets/commit/ac824f6b83178e34f015c296008d7a1e21c70878))
### Testing
- Adjust metadata assertions to new schema defaults
([`75697f5`](https://github.com/bec-project/bec_widgets/commit/75697f5b1faefb5cfcbc1b753d3f505d69339559))
- Adjust metadata assertions to new schema defaults
([`2697496`](https://github.com/bec-project/bec_widgets/commit/26974965151748c57334f350e21f3b610f92e011))
## v3.0.0 (2026-03-06)
### Bug Fixes
- 'any' type annotations
([`9c4a544`](https://github.com/bec-project/bec_widgets/commit/9c4a54493adc94afe5d43db5e8cbb8d565670af2))
- Add metadata to scan control export
([`17e678b`](https://github.com/bec-project/bec_widgets/commit/17e678b0ad1739490e901f3dbf7180d99c96950c))
- Address copilot review
([`a1a400f`](https://github.com/bec-project/bec_widgets/commit/a1a400f5409213ee1ab2f7cc9f8da7a2b612972d))
- Adjust ring progress bar to ads
([`7fd7f67`](https://github.com/bec-project/bec_widgets/commit/7fd7f67857e23b04759cf23993a99f4701121f95))
- Don't wait forever
([`c1d0e43`](https://github.com/bec-project/bec_widgets/commit/c1d0e435d5dd9965dbafd5bf469327c7f7620cfd))
- Removal of old BECDock import
([`92ae5fc`](https://github.com/bec-project/bec_widgets/commit/92ae5fc7fbf3a55068e2b42d3f66134baeb71766))
- Remove manual stylesheet deletion/override
([`8bbd519`](https://github.com/bec-project/bec_widgets/commit/8bbd519559c857cdc9f51e9507994e7aa4b07af1))
- Remove singleShots from BECConnector and adjustments of dock area logic
([`e26a90c`](https://github.com/bec-project/bec_widgets/commit/e26a90c62fa6c176bf4425867d1cb895a6fad7cd))
- Sanitize name space util for bec connector and ads
([`beca23e`](https://github.com/bec-project/bec_widgets/commit/beca23e14e18445f6ee440e8c55b57f4180a36c9))
- Tooltip logic and disable button on running scan
([`fa56fc8`](https://github.com/bec-project/bec_widgets/commit/fa56fc88026521f6f13690c4ec621c79e318f434))
- **_OverlayEventFilter**: Fix typo
([`a9f92cf`](https://github.com/bec-project/bec_widgets/commit/a9f92cf15547d207a614a1ed08b5d763a569fe59))
- **advanced_dock_area**: Cli API adjustments docs + names
([`6883982`](https://github.com/bec-project/bec_widgets/commit/6883982bf67c5fff02d72fbe39425af39bc3a65e))
- **advanced_dock_area**: Empty profile is always empty
([`aba67d3`](https://github.com/bec-project/bec_widgets/commit/aba67d3129581c85467ddd83211a03ea51c157a3))
- **advanced_dock_area**: Ensure the general profile exists when launched first time
([`7d2760e`](https://github.com/bec-project/bec_widgets/commit/7d2760eab8e5494992adb1452705f58619842d30))
- **advanced_dock_area**: New profiles are saved with quickselect as default
([`0d6b94a`](https://github.com/bec-project/bec_widgets/commit/0d6b94aaecb56e51bdc1ff930079b6c5535798de))
- **advanced_dock_area**: Profile behaviour adjusted, cleanup of the codebase
([`22df7bb`](https://github.com/bec-project/bec_widgets/commit/22df7bb5320c3b1808ab21e6354350838f5acb63))
- **advanced_dock_area**: Remove all widgets when loading new profiles
([`b841cfb`](https://github.com/bec-project/bec_widgets/commit/b841cfbc5f5021c1f9bea03e7fe88713506f66a7))
- **advanced_dock_area**: Remove widget from dock area by object name
([`8f44213`](https://github.com/bec-project/bec_widgets/commit/8f44213ecccca882f22b8738baef28b68d99c381))
- **advanced_dock_area**: Removed non-functional dock_list and dock_map from RPC
([`88b6e01`](https://github.com/bec-project/bec_widgets/commit/88b6e015bf1ab3b56db843ec13a6473ad67c4acc))
- **advanced_dock_area**: Removed the singleShot for load_initial_profile
([`3236dfb`](https://github.com/bec-project/bec_widgets/commit/3236dfb07f477fb87bcbcd0ee983781d5281beb6))
- **advanced_dock_area**: Replace sanitize_namespace with slugify
([`013b916`](https://github.com/bec-project/bec_widgets/commit/013b916ca3beb7a47db9009b9e07250ae52979b1))
- **basic_dock_area**: Delete_all will also delete floating docks
([`6b2b42f`](https://github.com/bec-project/bec_widgets/commit/6b2b42f21afa98d4ee5cb9d969aaa21cfc633f4e))
- **basic_dock_area**: Removed the singleShot usage
([`6cff8d7`](https://github.com/bec-project/bec_widgets/commit/6cff8d7a41f6f08908c3dd20fd563ab2612976e3))
- **bec_connector**: Use RPC register to fetch all connections
([`56b1e66`](https://github.com/bec-project/bec_widgets/commit/56b1e6687f4ce56e7c836678d397d1ca0fbec459))
- **bec_connector**: Widget_removed and name_established signals added
([`389a93f`](https://github.com/bec-project/bec_widgets/commit/389a93f8d07d44c17772e6183ee129db7692bd89))
- **bec_widget**: Improved qt enums; grab safeguard
([`f38cd3e`](https://github.com/bec-project/bec_widgets/commit/f38cd3e3a043151ce25f91d9a6b325a6c6ac5103))
- **bec_widgets**: Adapt to bec_qthemes 1.0; themes can be only applied on living Qt objects
([`b0cd619`](https://github.com/bec-project/bec_widgets/commit/b0cd619d7dff8f7ce7bc37ea6acea9473b2273d8))
- **becconnector**: Ophyd thread killer on exit + in conftest
([`0b9e5c1`](https://github.com/bec-project/bec_widgets/commit/0b9e5c15afb8b6f271992cb70c235c2be44c24a8))
- **becconnector**: Sanitize the setObjectName from qobject inheritance
([`7507f27`](https://github.com/bec-project/bec_widgets/commit/7507f27d686300a2b42c80dc06f3c78142c7ef84))
- **busy-loader**: Adjust busy loader and tests
([`94faaba`](https://github.com/bec-project/bec_widgets/commit/94faaba24d45a1ff971879486fa044fce49d2d5c))
- **CLI**: Change the default behavior of launching the profiles in CLI
([`b43b6e8`](https://github.com/bec-project/bec_widgets/commit/b43b6e844b4f178f9636b325aee0ce4fa2152199))
- **CLI**: Dock_area can be created from CLI with specific profile or empty
([`9c66dd5`](https://github.com/bec-project/bec_widgets/commit/9c66dd59914e2c8964f811f4e7e522fd3ae75633))
- **cli**: Rpc API from any folder
([`b29648e`](https://github.com/bec-project/bec_widgets/commit/b29648e10b0ea7931ad216221f231b77ab8998d8))
- **client**: Abort, reset, stop button removed from RPC access
([`c923f79`](https://github.com/bec-project/bec_widgets/commit/c923f7929370c3ac721dfa84d7cafcd0aa406c92))
- **client**: Client API regenerated
([`7083f94`](https://github.com/bec-project/bec_widgets/commit/7083f94f467ad4d40bea57dcdc96c75aa3690910))
- **client_utils**: Delete is deleting window and its content
([`be55bf2`](https://github.com/bec-project/bec_widgets/commit/be55bf20c1295c1e710457638c1bc7154b23011e))
- **client_utils**: Safeguard for accessing gui.new and launcher if GUIServer not running
([`4d41be6`](https://github.com/bec-project/bec_widgets/commit/4d41be61b546931c728b584f190aa4de3f418dd3))
- **colors**: Added logger to the apply theme
([`1f363d9`](https://github.com/bec-project/bec_widgets/commit/1f363d9bd4e6f7a01edcbe5d0049560459d184d0))
- **colors**: More benevolent fetching of colormap names, avoid hardcoded wrong colormap mapping
from GradientWidget from pg
([`cd9c7ab`](https://github.com/bec-project/bec_widgets/commit/cd9c7ab079bee1623a93ff63142cac8ebf61facd))
- **dark_mode_button**: Rpc access disabled
([`4fc2522`](https://github.com/bec-project/bec_widgets/commit/4fc252220d3a22f52b1148ba64045f5884d59182))
- **dark_mode_button**: Skip settings added
([`1c18810`](https://github.com/bec-project/bec_widgets/commit/1c18810e5faf0de96bb7381db3d8c4bcd2596596))
- **developer widget**: Save before executing a scripts
([`d085f65`](https://github.com/bec-project/bec_widgets/commit/d085f651532f84e720506745dbd44b80fb05a4be))
- **device-form-dialog**: Adapt device-form-dialog ophyd validation test
([`36be529`](https://github.com/bec-project/bec_widgets/commit/36be5292da1a2c30ef9a8493ad49f361d878c23a))
- **device-form-dialog**: Adapt DeviceFormDialog to run validation of config upon editing/adding a
config, and forward validation results
([`7c28364`](https://github.com/bec-project/bec_widgets/commit/7c283645948999f6a6b2e480418e5c8c7f158fb5))
- **device-init-progress-bar**: Fix ui format for device init progressbar
([`caba3a5`](https://github.com/bec-project/bec_widgets/commit/caba3a55f3a7a62a74f8f36b14a960e9c0fe0981))
- **device-manager**: Fix minor icon synchronization bugs
([`1d654bd`](https://github.com/bec-project/bec_widgets/commit/1d654bd8bdaac581a934cb9bab5a64a9021b4972))
- **device-manager-display-widget**: Fix error message popup on cancelling upload
([`fa49322`](https://github.com/bec-project/bec_widgets/commit/fa49322d1fd94ec4235c435dd6ca5e5234cd6bcc))
- **device-manager-display-widget**: Remove devices from ophyd validation after upload to BEC
([`7805c7a`](https://github.com/bec-project/bec_widgets/commit/7805c7a1916d8d153881eaf6b96825a010ad6a9c))
- **device-progress-bar**: Remove stretch in content layout
([`3fe6a00`](https://github.com/bec-project/bec_widgets/commit/3fe6a00708c459595b2eedb2a902c4ca5cae7171))
- **device_combobox**: Public flag for valid input
([`6c73307`](https://github.com/bec-project/bec_widgets/commit/6c73307bb43dfc2ae6181bd4be3854b7e198eb1d))
- **device_input_widgets**: Removed RPC access
([`940face`](https://github.com/bec-project/bec_widgets/commit/940face1187a0d3480ca3d64c061550271ff54e4))
- **dock_area**: Profile management with empty profile, applied across the whole repo
([`963941a`](https://github.com/bec-project/bec_widgets/commit/963941a788c1ce8a5def15b9a9d930ef9c62f41e))
- **dock_area**: Tabbed dock have correct parent
([`a632f35`](https://github.com/bec-project/bec_widgets/commit/a632f35c40e8323378f2464a6a82a484edf4ff33))
- **dock_area**: The old BECDockArea(pg) removed and replaces by AdvancedDockArea(ADS)
([`a6583ad`](https://github.com/bec-project/bec_widgets/commit/a6583ad53f6a1004af1a87904517d97a52801116))
- **dock_area**: Widget_map and widget_list by default returns only becconnector based widgets
([`3a5317b`](https://github.com/bec-project/bec_widgets/commit/3a5317be53d21130203a534b0dbf6bbef2d1a1c8))
- **editors**: Vscode widget removed
([`48387c0`](https://github.com/bec-project/bec_widgets/commit/48387c0ad9234f5f7600644eb12fa12c6d29efa7))
- **FakeDevice**: Add _info dict
([`2992939`](https://github.com/bec-project/bec_widgets/commit/2992939b0fa504418fe06173c11702e9dd4f3ce2))
- **general_app**: Old general app example removed
([`3ebac55`](https://github.com/bec-project/bec_widgets/commit/3ebac55e2d6aabf971d818fddb53430a690a7392))
- **guided-tour**: Fix skip past invalid step for 'prev' step
([`7bcdc31`](https://github.com/bec-project/bec_widgets/commit/7bcdc31f119b7b0996c7eac75008cef0b6e880ff))
- **heatmap**: Devices are saved as SafeProperties
([`6baf196`](https://github.com/bec-project/bec_widgets/commit/6baf1962faa0628ba872790e6cb34565bc7d0d7c))
- **heatmap**: Interpolation of the image moved to separate thread
([`323c8d5`](https://github.com/bec-project/bec_widgets/commit/323c8d5bc00f12b2d032f3da5daa47ef3e4774bc))
- **heatmap**: Interpolation thread is killed only on exit, logger for dandling thread
([`6fc524c`](https://github.com/bec-project/bec_widgets/commit/6fc524c819903eedd690adcb09f7aa70ee4d2248))
- **launch_window**: Argument to start with the gui class
([`3c16909`](https://github.com/bec-project/bec_widgets/commit/3c16909a875337efdec9e984f952c390ce99cfb4))
- **launch_window**: Launch geometry for widgets launched from launcher to 80% of the primary screen
as default
([`6459281`](https://github.com/bec-project/bec_widgets/commit/6459281387c8f1287347b9569a77aa1e9444013c))
- **launch_window**: Logic for showing launcher
([`d9b7285`](https://github.com/bec-project/bec_widgets/commit/d9b728584fb7e96ebac1c0f29f713290c0092556))
- **launch_window**: Processevents removed
([`c61d00e`](https://github.com/bec-project/bec_widgets/commit/c61d00e761851a67003921c2ad689238e360ad77))
- **main_app**: Center the application window on the screen
([`96a52a0`](https://github.com/bec-project/bec_widgets/commit/96a52a0cb0fb248e83303ee89182fe4ebeb29e75))
- **main_app**: Dock area from main app shares the workspace name with the CLI one to reuse the
profiles created in the cli companion window
([`06745e0`](https://github.com/bec-project/bec_widgets/commit/06745e0511d3ad4e261119118c7767f92bd884a5))
- **main_app**: Refactor main function and update script entry point in pyproject.toml
([`7ccfcc9`](https://github.com/bec-project/bec_widgets/commit/7ccfcc9f52c6ddaf65c350d474bac7260e3dd059))
- **main_app**: Rpc access refined
([`5bcf440`](https://github.com/bec-project/bec_widgets/commit/5bcf440be7172f8c3cadc7cd1d95251c176d33d1))
- **main_app**: Temporarily disable IDE view
([`bfc9f19`](https://github.com/bec-project/bec_widgets/commit/bfc9f1947234b87835d2cde87f961a00b1a0990d))
- **main_app**: The dock area view implemented as a viewBase
([`ab9688d`](https://github.com/bec-project/bec_widgets/commit/ab9688d2b551e4b3525fe9aed76afd772b835b05))
- **main_window**: Cleanup adjusted with shiboken6
([`06cb187`](https://github.com/bec-project/bec_widgets/commit/06cb187d1a030e24d62c5a8e01978ba68f4812df))
- **main_window**: Delete on close
([`522934f`](https://github.com/bec-project/bec_widgets/commit/522934f8cd814c07fde8c62635f2f63ed716e00e))
- **main_window**: Parent fixed for notification broker
([`947bf63`](https://github.com/bec-project/bec_widgets/commit/947bf63e03b3cbdfe2fd8ab803c83175c7bc599b))
- **main_window**: Removed general forced cleanup
([`cab4227`](https://github.com/bec-project/bec_widgets/commit/cab422777c50151b94da71a45a9bda0e1ce2804d))
- **main_window**: Safeguard of fetching the launcher from the main window if GUIServer is not
running
([`f8be437`](https://github.com/bec-project/bec_widgets/commit/f8be43741a5c100a976d2f84c3dc7607938c847e))
- **main_window**: Scan progress bar rpc not exposed
([`04b448e`](https://github.com/bec-project/bec_widgets/commit/04b448e1832796616002a1ea26028e3d42aca9b1))
- **monaco dock**: Update last focused editor when closing
([`3631fc2`](https://github.com/bec-project/bec_widgets/commit/3631fc26499853015ff58283c2b8913aa9a36334))
- **monaco widget**: Reset current_file
([`c53d4c0`](https://github.com/bec-project/bec_widgets/commit/c53d4c0ad7b4c423eaa13828e2b38a04751f148e))
- **monaco_dock**: Update editor metadata handling and improve open_file method
([`3136477`](https://github.com/bec-project/bec_widgets/commit/31364772bd7fcccbc118061d0b601a9f1121bcb0))
- **motor_map**: X/y motor are saved in properties
([`96060fc`](https://github.com/bec-project/bec_widgets/commit/96060fca53f3426dbc43f1ae5d8ebdd7acc39100))
- **ophyd-validation**: Add device_manager_ds argument if available for ophyd validation
([`338ff45`](https://github.com/bec-project/bec_widgets/commit/338ff455cccfc1e8a3b0638fdcc4f1d807f0b6ca))
- **positioner_box**: Layout HV centered and size taken from the ui file
([`6113deb`](https://github.com/bec-project/bec_widgets/commit/6113debc6c1d95a50b7522144fdc820380ae2e28))
- **qt_ads**: Pythons stubs match structure of PySide6QtAds
([`2f9d6d5`](https://github.com/bec-project/bec_widgets/commit/2f9d6d59eee32e373acc0df8a38b426d8142562b))
- **rpc**: Rpc flags adjustment for MainApp and DeveloperWidget
([`5b15c75`](https://github.com/bec-project/bec_widgets/commit/5b15c75b88707f450bfa194d9eed3d726e101981))
- **rpc_register**: Listing only valid connections
([`38eb244`](https://github.com/bec-project/bec_widgets/commit/38eb2441cdf677939354c7066f854c22cf261932))
- **rpc_server**: Add check for rpc_exposed to serialize_object
([`0eabd0f`](https://github.com/bec-project/bec_widgets/commit/0eabd0f72be6247073382d0df02776d30c35a1aa))
- **rpc_server**: Removed unused get _get_becwidget_ancestor
([`047ff2b`](https://github.com/bec-project/bec_widgets/commit/047ff2bef77ca14f060b3b0bc21f78b880535faa))
- **rpc_server**: Use single shot instead of processEvents to avoid dead locks
([`84d6653`](https://github.com/bec-project/bec_widgets/commit/84d6653d1993dd4bebb98fcbf0d1a0dd94119502))
- **scatter waveform**: Fix tab order for settings panel
([`08e1985`](https://github.com/bec-project/bec_widgets/commit/08e19858eadb738358465c9f2a202529d1ccbe45))
- **scatter_waveform**: Devices and entries saved as properties
([`7ab8e0c`](https://github.com/bec-project/bec_widgets/commit/7ab8e0c2ed4f1b49e943f7ec64d3984ede6e134a))
- **scatter_waveform**: Modernization of scatter waveform settings dialog
([`dea73a9`](https://github.com/bec-project/bec_widgets/commit/dea73a97c9f78560e9f11290ba442152cc955057))
- **scatter_waveform**: Remove curve_json from the properties
([`f6712e8`](https://github.com/bec-project/bec_widgets/commit/f6712e8bb855566ca0f308ae3d5bf5109d98d792))
- **screen_utils**: Screen utilities added and fixed sizing for widgets from launch window and main
app
([`fb55e72`](https://github.com/bec-project/bec_widgets/commit/fb55e72713a2209575c555c9dd8c025a0349e795))
- **server**: Gui server can reach shutdown, logic moved to becconnector
([`0d05839`](https://github.com/bec-project/bec_widgets/commit/0d05839e9e3f4c61fc318aa44721436afcebf06f))
- **signal-label**: Fix signal label cleanup, missing parent in constructors
([`72639e7`](https://github.com/bec-project/bec_widgets/commit/72639e7e5fa01ceac6cc864c01cea73f4ddca441))
- **signal_combo_box**: Get_signal_name added; remove duplicates from heatmap and scatter waveform
settings;
([`66a9510`](https://github.com/bec-project/bec_widgets/commit/66a95102dd33dbac5575a3b0d99c4c99c42cce4a))
- **signal_label**: Dispatcher unsubscribed in the cleanup
([`90ba505`](https://github.com/bec-project/bec_widgets/commit/90ba505c10e7ee60d82abb578c7f691cf1125e9a))
- **toggle**: Move toggle to theme colors
([`375d131`](https://github.com/bec-project/bec_widgets/commit/375d131109d37ea7b49aa354b624b0dd8fea89ee))
- **view**: Based on BECWidgets
([`3d049d6`](https://github.com/bec-project/bec_widgets/commit/3d049d67a9303b20862150b3622c4121d4a72b32))
- **web_console**: Added startup kwarg
([`55c8a57`](https://github.com/bec-project/bec_widgets/commit/55c8a57e71653299f3fd66ca7aafca8f32c7aacc))
- **widget_state_manager**: Added shiboken check
([`338b9e1`](https://github.com/bec-project/bec_widgets/commit/338b9e1aa7216d9d38449633fe9d4fffce13ee90))
- **widget_state_manager**: Filtering of not wanted properties
([`7ea4352`](https://github.com/bec-project/bec_widgets/commit/7ea4352a09349e606c97edb72eccf6e683684cf8))
- **widget_state_manager**: Properties_to_skip are not restored even if in ini file
([`84c7360`](https://github.com/bec-project/bec_widgets/commit/84c7360bb8a63426d584a522d6a8969810536d2a))
- **widget_state_manager**: State manager can save all properties recursively to already existing
settings
([`98e2979`](https://github.com/bec-project/bec_widgets/commit/98e29792a2620a9e88c770cd69d7cad88cc94252))
- **widgets**: Processevent removed from widgets using it
([`a56bd57`](https://github.com/bec-project/bec_widgets/commit/a56bd572a000e47dd7d1d2a458dac676e67ec21e))
- **widgets**: Removed isVisible from all SafeProperties
([`b72bf4a`](https://github.com/bec-project/bec_widgets/commit/b72bf4a0f9a67c104cd86c66e9160ab9f0a40c01))
### Build System
- Pyside6-qtads; bec_qtheme V1; dependencies updated and adjusted
([`562001c`](https://github.com/bec-project/bec_widgets/commit/562001c08cdc3ca9fbe28aaed8b6a83921426f97))
- **deps**: Update bec-qthemes requirement
([`4a44ede`](https://github.com/bec-project/bec_widgets/commit/4a44ede8fe02b4c513ec419f85cb447f58dfdf86))
Updates the requirements on [bec-qthemes](https://github.com/bec-project/bec_qthemes) to permit the
latest version. - [Release notes](https://github.com/bec-project/bec_qthemes/releases) -
[Changelog](https://github.com/bec-project/bec_qthemes/blob/main/CHANGELOG.md) -
[Commits](https://github.com/bec-project/bec_qthemes/compare/v0.7.0...v1.3.3)
--- updated-dependencies: - dependency-name: bec-qthemes dependency-version: 1.3.3
dependency-type: direct:production ...
Signed-off-by: dependabot[bot] <support@github.com>
### Code Style
- Wrap progress bar in widget to fix background
([`793779d`](https://github.com/bec-project/bec_widgets/commit/793779db68c9725fae767d6cd0096c89a4caa700))
### Continuous Integration
- Add artifact upload
([`d301fdf`](https://github.com/bec-project/bec_widgets/commit/d301fdfeb237acd61fd579a0e8147f2037df62d5))
- Cancel previous CI run for PR or branch
([`37298c2`](https://github.com/bec-project/bec_widgets/commit/37298c21c3b76667459f2a62453692e99ff8191e))
- Install ttyd
([`b6d70c3`](https://github.com/bec-project/bec_widgets/commit/b6d70c34df29d2f44e7f5da88cb0daaef39ceed1))
- Use shared issue sync action instead of local version
([`c9a8e64`](https://github.com/bec-project/bec_widgets/commit/c9a8e64217d3c2047a4a8f5e2348c0a725a0066a))
### Features
- Add export and load settings methods to BECConnector; add SafeProperty safe getter flag
([`5435fec`](https://github.com/bec-project/bec_widgets/commit/5435fec68a11caa83e8566cde21ad382729e6792))
- Add guided tour docs to device-manager-view
([`fcb4306`](https://github.com/bec-project/bec_widgets/commit/fcb43066e4abe469e0f06163b4abcce6e0d9250b))
- Add SafeConnect
([`4b5a45c`](https://github.com/bec-project/bec_widgets/commit/4b5a45c320d701e6878d6af7259c530596118053))
- Attach config cancellation to closeEvent
([`c1443fa`](https://github.com/bec-project/bec_widgets/commit/c1443fa27afc63c69c4b56cf8be7eb2792704784))
- Guided tour for main app
([`3ffdf11`](https://github.com/bec-project/bec_widgets/commit/3ffdf11c3e419d71e22c484c618eec51e9168f9d))
- **actions**: Actions can be created with label text with beside or under alignment
([`9c3a6e1`](https://github.com/bec-project/bec_widgets/commit/9c3a6e1691fd02230651a4d871911f365d4a3129))
- **ads**: Add pyi stub file to provide type hints for ads
([`4c4fc25`](https://github.com/bec-project/bec_widgets/commit/4c4fc25a42be9bc8ecce6f550c4f357372233289))
- **advanced_dock_area**: Added ads based dock area with profiles
([`d25314e`](https://github.com/bec-project/bec_widgets/commit/d25314e6eeb6323a6ffcde3c119f7b1bc0ebed16))
- **advanced_dock_area**: Created DockAreaWidget base class; profile management through namespaces;
dock area variants
([`58b88ef`](https://github.com/bec-project/bec_widgets/commit/58b88efcb66627f9e9c3c9de65366d55465e1e44))
- **advanced_dock_area**: Floating docks restore with relative geometry
([`440cecd`](https://github.com/bec-project/bec_widgets/commit/440cecddf740a5f320f53771b93a148fb3be544b))
- **advanced_dock_area**: Instance lock for multiple ads in same session
([`bcaf013`](https://github.com/bec-project/bec_widgets/commit/bcaf013d2b5b45830cc37079b7d0f388ead98bc1))
- **advanced_dock_area**: Ui/ux for profile management improved, saving directories logic adjusted
([`7305498`](https://github.com/bec-project/bec_widgets/commit/730549847563b552887a5529b2b0fed308ed8b98))
- **bec-login**: Add login widget in material design style
([`b798ea2`](https://github.com/bec-project/bec_widgets/commit/b798ea2340a6aa8c0325a1cd1995eba028279816))
- **bec_widget**: Attach/detach method for all widgets + client regenerated
([`82dbf31`](https://github.com/bec-project/bec_widgets/commit/82dbf31da54288b7228bc5c7bdc271a8178f8d02))
- **bec_widget**: Save screenshot to bytes
([`ed2651a`](https://github.com/bec-project/bec_widgets/commit/ed2651a914a283dc7cc45a9bf185d2a4e053d307))
- **becconnector**: Added rpc_passthrough_children flag in addition to rpc_exposed
([`010373f`](https://github.com/bec-project/bec_widgets/commit/010373fd5b334c6616efce467608356b36c2130b))
- **becconnector**: Exposed rpc flag added to the BECConnector
([`de6c628`](https://github.com/bec-project/bec_widgets/commit/de6c6284ad6d73b40137e9bba56e748c59a4ade9))
- **busy_loader**: Busy loader added to bec widget base class
([`92c15a7`](https://github.com/bec-project/bec_widgets/commit/92c15a7f829fa3f0b69cf5584ac45a21dce0b01d))
- **client_utils**: Theme can be changed from the CLI
([`c1d4758`](https://github.com/bec-project/bec_widgets/commit/c1d4758e4ca33d094fabdfbd4e024a2836f2fa9a))
- **color**: Add relative luminance calculation
([`a84b924`](https://github.com/bec-project/bec_widgets/commit/a84b924162280fc6b6ca31af511b78c4f5baafc9))
- **developer_view**: Add developer view
([`bdef594`](https://github.com/bec-project/bec_widgets/commit/bdef594b5885b5fab60ef94addbce1ab771c4244))
- **developer_widget**: Add signal connection for focused editor changes to disable run button for
macro files
([`fa79179`](https://github.com/bec-project/bec_widgets/commit/fa79179f89f048aeee0a3947350f3a7bc2169d9f))
- **device-initialization-progress-bar**: Add progress bar for device initialization
([`5deafb9`](https://github.com/bec-project/bec_widgets/commit/5deafb97979eb1a2e8bcba3321dfd1a15553a5da))
- **device-manager**: Add DeviceManager Widget for BEC Widget main applications
([`a6357af`](https://github.com/bec-project/bec_widgets/commit/a6357af8ffda640eaee1c1c75c3a4bdf0c5de068))
- **device_combobox**: Device filter added based on its signal classes
([`fbddf4a`](https://github.com/bec-project/bec_widgets/commit/fbddf4a28442dab6e9e4585aa0c3a0131d6bdf7b))
- **dm-view**: Initial device manager view added
([`9e4be38`](https://github.com/bec-project/bec_widgets/commit/9e4be38c0b8b6e654313bf232a597d09978d2436))
- **generate_cli**: Rpc API from content widget can be merged with the RPC API of the container
widget statically
([`758956b`](https://github.com/bec-project/bec_widgets/commit/758956be098d6629a0cd641b1525965ebfe19345))
- **guided_tour**: Add guided tour
([`9b753c1`](https://github.com/bec-project/bec_widgets/commit/9b753c1f24419292790ca60e4bd55bb1aa5e1a70))
- **help-inspector**: Add help inspector widget
([`5ac629d`](https://github.com/bec-project/bec_widgets/commit/5ac629de8c7bbdf0e2c07c9a7cf25e430cd031c1))
- **image**: Modernization of image widget
([`80c0dfa`](https://github.com/bec-project/bec_widgets/commit/80c0dfa4f28e3eb2c6f944a517c92f822f51266d))
- **jupyter_console_window**: Adjustment for general usage
([`66f3e51`](https://github.com/bec-project/bec_widgets/commit/66f3e517f0fb8fa1ea678ec09ef852d5b8a63d51))
- **main_app**: Main app with interactive app switcher
([`b30e1e4`](https://github.com/bec-project/bec_widgets/commit/b30e1e4c5e182903721fe7c16a8069f2c95704d3))
- **motor_map**: Motor selection adopted to splitter action
([`168bb3c`](https://github.com/bec-project/bec_widgets/commit/168bb3cb77ca3a270a958f4f941445383c8bec99))
- **plot_base**: Plot_base, image and heatmap widget adopted to property-toolbar sync
([`dd69578`](https://github.com/bec-project/bec_widgets/commit/dd69578b912b44130d33427fa8d5d948889e8c07))
- **SafeProperty**: Safeproperty emits property_changed signal
([`7cce3bd`](https://github.com/bec-project/bec_widgets/commit/7cce3bd54210f82a5cf68e6219ea073e972234d6))
- **signal_combobox**: Extended that can filter by signal class and dimension of the signal
([`cfd6bde`](https://github.com/bec-project/bec_widgets/commit/cfd6bde268cea5bd119354db8b6ab1661b575293))
- **toolbar**: Splitter action added
([`0752f3d`](https://github.com/bec-project/bec_widgets/commit/0752f3d6a9cd9b080bf87464eac9eb05f99f108f))
- **toolbar**: Toolbar can be synced with the property_changed for toggle actions
([`4357d98`](https://github.com/bec-project/bec_widgets/commit/4357d984c8f89fa51bc0c8d9a217b2a2028e3ca9))
- **web console**: Add support for shared web console sessions
([`5e111cf`](https://github.com/bec-project/bec_widgets/commit/5e111cfc54f2771a0ff5080a77bb4ac5b491bc8f))
- **widget_hierarchy_tree**: Widget displaying parent child hierarchy from the app widgets
([`5f46fa0`](https://github.com/bec-project/bec_widgets/commit/5f46fa09943017fdadbe12522b38a2733d5b6001))
- **widget_highlighter**: Reusable separate widget highlighter
([`8b782ac`](https://github.com/bec-project/bec_widgets/commit/8b782ac302b4ccbfe768c066c3c9fbe31fdace75))
- **widget_io**: Widget hierarchy can grap all bec connectors from the widget recursively
([`db83576`](https://github.com/bec-project/bec_widgets/commit/db83576346980eef59b5366bc07258edcbf6333b))
### Performance Improvements
- **heatmap**: Thread worker optimization
([`f98a5de`](https://github.com/bec-project/bec_widgets/commit/f98a5de7e9f154e6e9fc65a257776c9dec74eb84))
### Refactoring
- Add extra tour steps, add enter button
([`2826919`](https://github.com/bec-project/bec_widgets/commit/2826919c5a330e2ba9666cfec1f9561b4cfd4bcf))
- Global refactoring to use device-signal pair names
([`b93fbc5`](https://github.com/bec-project/bec_widgets/commit/b93fbc5cd31dbaa1bf4b18b9d30e3463ea539f72))
- Improvements to enum access
([`19b7310`](https://github.com/bec-project/bec_widgets/commit/19b73104337a100cef39936dd7ec5c32c346f99b))
- **advanced_dock_area**: Change remove_widget to delete
([`eda30e3`](https://github.com/bec-project/bec_widgets/commit/eda30e31396ec1e34c13be047564de334d9a5c6f))
- **bec_main_window**: Main app theme renamed to View
([`37bfad7`](https://github.com/bec-project/bec_widgets/commit/37bfad7174982f7c3489e38cf715615719b34862))
- **busy-loader**: Refactor busy loader to use custom widget
([`332ca20`](https://github.com/bec-project/bec_widgets/commit/332ca205c12c445513472a25366699e870e5a879))
- **busy-loager**: Improve eventFilter to avoid crashs if target or overlay is None.
([`229da62`](https://github.com/bec-project/bec_widgets/commit/229da6244ae2bb2521ff0257db1772e5cceeee59))
- **developer_view**: Changed to use DockAreaWidget
([`4d40918`](https://github.com/bec-project/bec_widgets/commit/4d40918b7c84c833d46287fec365d1810683adec))
- **developer_widget**: Enhance documentation and add missing imports
([`5e0c376`](https://github.com/bec-project/bec_widgets/commit/5e0c3767742bcac8b39972d0972db0580c1863cd))
- **device-form-dialog**: Use native QDialogButtonBox instead of GroupBox layout
([`12b4d3a`](https://github.com/bec-project/bec_widgets/commit/12b4d3a9e0ffe0539d5884bbedf4f14349a5e117))
- **dock_area**: Change name to BECDockArea
([`71ed2d3`](https://github.com/bec-project/bec_widgets/commit/71ed2d353acc0e68eaef1fa55474db0b8e1f1eb9))
- **guided-tour**: Add support for QTableWidgetItem
([`83489b7`](https://github.com/bec-project/bec_widgets/commit/83489b7519f41b75f2d3f2cdcf31b0075e41d52d))
- **main_app**: Adapted for DockAreaWidget changes
([`ac850ec`](https://github.com/bec-project/bec_widgets/commit/ac850ec650695c12a77e0e8e598094d740312a89))
- **main_app**: Simpler id and object name management
([`654aeb7`](https://github.com/bec-project/bec_widgets/commit/654aeb711626f0f85d288cd3c0a85d69ad2826d8))
- **monaco_dock**: Changed to use DockAreaWidget
([`ed0d34a`](https://github.com/bec-project/bec_widgets/commit/ed0d34a60f8348a970da71d77801154ea70c24c6))
- **ophyd-validation**: Allow option to keep device visible after successful validation
([`89d5c5a`](https://github.com/bec-project/bec_widgets/commit/89d5c5abdb0081e29d2c31ae6ded75a3f9abe0ff))
- **widget_io**: Hierarchy logic generalized
([`00bf01c`](https://github.com/bec-project/bec_widgets/commit/00bf01c1290c4ead6d8270942fbfda2cbd7e9873))
### Testing
- Fix test
([`de835e8`](https://github.com/bec-project/bec_widgets/commit/de835e81d8cf0ec6d3bca9d07ac21d4737666e31))
- **config-communicator**: Add test for cancel action
([`24701c2`](https://github.com/bec-project/bec_widgets/commit/24701c2a270520de739e4615d0f52a6386bbadc0))
- **device-form-dialog**: Adapt tests
([`f827e77`](https://github.com/bec-project/bec_widgets/commit/f827e77e870109b21e10b4cc28d6c09b8f77b2a6))
- **device-manager**: Use mocked client for tests
([`836fedd`](https://github.com/bec-project/bec_widgets/commit/836fedd50e4fdb66bd7614a55c8e0f95a14c3fac))
- **device-manager-view**: Improve test coverage for device-manager-view
([`4edc571`](https://github.com/bec-project/bec_widgets/commit/4edc57158be30d2500ad04d1b015bc8627cfb873))
- **e2e**: Raise with widget name
([`3f76ade`](https://github.com/bec-project/bec_widgets/commit/3f76ade6289a75b76d7a5f67e9d72175378bedbe))
- **script_tree**: Improve hover event handling with waitUntil
([`6296055`](https://github.com/bec-project/bec_widgets/commit/6296055c664070b8caeffda3c7047774bd692691))
- **widget_io**: Add dedicated unit tests for iter_widget_tree and helper methods
([`041afc6`](https://github.com/bec-project/bec_widgets/commit/041afc68b1c7202a4609149e6f0e212fca629c87))
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
## v2.45.14 (2026-01-23)
### Bug Fixes
- **bec_status**: Adjust bec status widget to info and version signature
([`709ffd6`](https://github.com/bec-project/bec_widgets/commit/709ffd6927dceb903cbd0797fc162e56aef378c1))
### Continuous Integration
- Use auth.token instead of login_or_token
([`0349c87`](https://github.com/bec-project/bec_widgets/commit/0349c872612ab0506e5662b813e78200a76d7590))
### Testing
- **device config**: Validate against pydantic
([`de8fe3b`](https://github.com/bec-project/bec_widgets/commit/de8fe3b5f503ace17b0064d2ce9f54662b0fb77e))
- **scan control**: Avoid strict length comparisons
([`d577fac`](https://github.com/bec-project/bec_widgets/commit/d577fac02fed11b2b1c44704c04fd111c2fed1d3))
## v2.45.13 (2025-12-17)
### Bug Fixes

View File

@@ -1,45 +1,42 @@
from __future__ import annotations
from typing import Literal
from bec_lib import bec_logger
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
logger = bec_logger.logger
def dock_area(
object_name: str | None = None, profile: str | None = None, start_empty: bool = False
) -> AdvancedDockArea:
object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None
) -> BECDockArea:
"""
Create an advanced dock area using Qt Advanced Docking System.
Args:
object_name(str): The name of the advanced dock area.
profile(str|None): Optional profile to load; if None the "general" profile is used.
start_empty(bool): If True, start with an empty dock area when loading specified profile.
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
the workspace:
- None: start empty
- "restore": restore last used profile
- "skip": do not initialize profile state
- "<name>": load specific profile
Returns:
AdvancedDockArea: The created advanced dock area.
BECDockArea: The created advanced dock area.
Note:
The "general" profile is mandatory and will always exist. If manually deleted,
it will be automatically recreated.
"""
# Default to "general" profile when called from CLI without specifying a profile
effective_profile = profile if profile is not None else "general"
widget = AdvancedDockArea(
widget = BECDockArea(
object_name=object_name,
restore_initial_profile=True,
root_widget=True,
profile_namespace="bec",
init_profile=effective_profile,
start_empty=start_empty,
)
logger.info(
f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}"
startup_profile=startup_profile,
)
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
return widget
@@ -51,7 +48,7 @@ def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
object_name(str): The name of the dock area.
Returns:
AdvancedDockArea: The created dock area.
BECDockArea: The created dock area.
"""
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -27,14 +27,12 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
get_last_profile,
list_profiles,
)
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock_area.profile_utils import get_last_profile, list_profiles
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
@@ -45,6 +43,7 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)"
class LaunchTile(RoundedFrame):
@@ -78,23 +77,28 @@ class LaunchTile(RoundedFrame):
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.Antialiasing, True)
painter.setRenderHints(QPainter.RenderHint.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
pixmap = pixmap.scaled(
size,
size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
@@ -104,7 +108,7 @@ class LaunchTile(RoundedFrame):
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setAlignment(Qt.AlignCenter)
self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Shrink font if the default would wrap on this platform / DPI
content_width = (
@@ -120,13 +124,13 @@ class LaunchTile(RoundedFrame):
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignCenter)
self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
@@ -136,13 +140,14 @@ class LaunchTile(RoundedFrame):
else:
self.selector = None
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.spacer_bottom = QSpacerItem(
0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
)
self.layout.addItem(self.spacer_bottom)
# Action button
self.action_button = QPushButton("Open")
self.action_button.setStyleSheet(
"""
self.action_button.setStyleSheet("""
QPushButton {
background-color: #007AFF;
border: none;
@@ -154,9 +159,8 @@ class LaunchTile(RoundedFrame):
QPushButton:hover {
background-color: #005BB5;
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
""")
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
@@ -179,12 +183,14 @@ class LaunchTile(RoundedFrame):
metrics = QFontMetrics(font)
label.setFont(font)
label.setWordWrap(False)
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.ElideRight, max_width))
class LaunchWindow(BECMainWindow):
RPC = True
PLUGIN = False
TILE_SIZE = (250, 300)
DEFAULT_LAUNCH_SIZE = (800, 600)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
@@ -209,7 +215,7 @@ class LaunchWindow(BECMainWindow):
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
@@ -318,7 +324,7 @@ class LaunchWindow(BECMainWindow):
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
@@ -347,7 +353,7 @@ class LaunchWindow(BECMainWindow):
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
"""
Refresh the dock-area profile selector, optionally preserving the selection.
Sets the combobox to the last used profile or "general" if no selection preserved.
Defaults to Start Empty when no valid selection can be preserved.
Args:
preserve_selection(bool): Whether to preserve the current selection or not.
@@ -362,9 +368,10 @@ class LaunchWindow(BECMainWindow):
)
profiles = list_profiles("bec")
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
selector.blockSignals(True)
selector.clear()
for profile in profiles:
for profile in selector_items:
selector.addItem(profile)
if selected_text:
@@ -373,21 +380,31 @@ class LaunchWindow(BECMainWindow):
if idx >= 0:
selector.setCurrentIndex(idx)
else:
# Selection no longer exists, fall back to last profile or "general"
# Selection no longer exists, fall back to default startup selection.
self._set_selector_to_default_profile(selector, profiles)
else:
# No selection to preserve, use last profile or "general"
# No selection to preserve, use default startup selection.
self._set_selector_to_default_profile(selector, profiles)
selector.blockSignals(False)
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
"""
Set the selector to the last used profile or "general" as fallback.
Set the selector default.
Preference order:
1) Start Empty option (if available)
2) Last used profile
3) First available profile
Args:
selector(QComboBox): The combobox to set.
profiles(list[str]): List of available profiles.
"""
start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly)
if start_empty_idx >= 0:
selector.setCurrentIndex(start_empty_idx)
return
# Try to get last used profile
last_profile = get_last_profile(namespace="bec")
if last_profile and last_profile in profiles:
@@ -396,13 +413,6 @@ class LaunchWindow(BECMainWindow):
selector.setCurrentIndex(idx)
return
# Fall back to "general" profile
if "general" in profiles:
idx = selector.findText("general", Qt.MatchFlag.MatchExactly)
if idx >= 0:
selector.setCurrentIndex(idx)
return
# If nothing else, select first item
if selector.count() > 0:
selector.setCurrentIndex(0)
@@ -428,7 +438,9 @@ class LaunchWindow(BECMainWindow):
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea)
if geometry is None and launch_script != "custom_ui_file":
geometry = self._default_launch_geometry()
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name)
# If name already exists, generate a unique one with counter suffix
@@ -451,13 +463,13 @@ class LaunchWindow(BECMainWindow):
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
return self._launch_auto_update(auto_update, geometry=geometry)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
return self._launch_widget(widget, geometry=geometry)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
@@ -469,13 +481,13 @@ class LaunchWindow(BECMainWindow):
logger.info(f"Created new dock area: {name}")
if isinstance(result_widget, BECMainWindow):
self._apply_window_geometry(result_widget, geometry)
apply_window_geometry(result_widget, geometry)
result_widget.show()
else:
window = BECMainWindowNoRPC()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
self._apply_window_geometry(window, geometry)
apply_window_geometry(window, geometry)
window.show()
return result_widget
@@ -511,12 +523,14 @@ class LaunchWindow(BECMainWindow):
window.setCentralWidget(loaded)
window.setWindowTitle(f"BEC - {filename}")
self._apply_window_geometry(window, None)
apply_window_geometry(window, None)
window.show()
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
def _launch_auto_update(
self, auto_update: str, geometry: tuple[int, int, int, int] | None = None
) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
@@ -527,11 +541,13 @@ class LaunchWindow(BECMainWindow):
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {window.objectName()}")
self._apply_window_geometry(window, None)
apply_window_geometry(window, geometry)
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
def _launch_widget(
self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None
) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
@@ -544,7 +560,7 @@ class LaunchWindow(BECMainWindow):
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
self._apply_window_geometry(window, None)
apply_window_geometry(window, geometry)
window.show()
return window
@@ -575,11 +591,14 @@ class LaunchWindow(BECMainWindow):
"""
tile = self.tiles.get("dock_area")
if tile is None or tile.selector is None:
profile = None
startup_profile = None
else:
selection = tile.selector.currentText().strip()
profile = selection if selection else None
return self.launch("dock_area", profile=profile)
if selection == START_EMPTY_PROFILE_OPTION:
startup_profile = None
else:
startup_profile = selection if selection else None
return self.launch("dock_area", startup_profile=startup_profile)
def _open_widget(self):
"""
@@ -592,30 +611,9 @@ class LaunchWindow(BECMainWindow):
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
def _apply_window_geometry(
self, window: QWidget, geometry: tuple[int, int, int, int] | None
) -> None:
"""Apply a provided geometry or center the window with an 80% layout."""
if geometry is not None:
window.setGeometry(*geometry)
return
default_geometry = self._default_window_geometry(window)
if default_geometry is not None:
window.setGeometry(*default_geometry)
else:
window.resize(window.minimumSizeHint())
@staticmethod
def _default_window_geometry(window: QWidget) -> tuple[int, int, int, int] | None:
screen = window.screen() or QApplication.primaryScreen()
if screen is None:
return None
available = screen.availableGeometry()
width = int(available.width() * 0.8)
height = int(available.height() * 0.8)
x = available.x() + (available.width() - width) // 2
y = available.y() + (available.height() - height) // 2
return x, y, width, height
def _default_launch_geometry(self) -> tuple[int, int, int, int] | None:
width, height = self.DEFAULT_LAUNCH_SIZE
return centered_geometry_for_app(width=width, height=height)
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
@@ -706,7 +704,7 @@ class LaunchWindow(BECMainWindow):
self.hide()
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import apply_theme

View File

@@ -1,3 +1,5 @@
from bec_qthemes import material_icon
from qtpy.QtGui import QAction # type: ignore
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
@@ -5,13 +7,22 @@ from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.utils.guided_tour import GuidedTour
from bec_widgets.utils.name_utils import sanitize_namespace
from bec_widgets.utils.screen_utils import (
apply_centered_size,
available_screen_geometry,
main_app_size_for_screen,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class BECMainApp(BECMainWindow):
RPC = False
PLUGIN = False
def __init__(
self,
@@ -43,51 +54,49 @@ class BECMainApp(BECMainWindow):
self._add_views()
# Initialize guided tour
self.guided_tour = GuidedTour(self)
self._setup_guided_tour()
def _add_views(self):
self.add_section("BEC Applications", "bec_apps")
self.ads = AdvancedDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
self.ads.setObjectName("MainWorkspace")
self.dock_area = DockAreaView(self)
self.device_manager = DeviceManagerView(self)
self.developer_view = DeveloperView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
)
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
self.add_view(
icon="display_settings",
title="Device Manager",
id="device_manager",
widget=self.device_manager,
mini_text="DM",
)
self.add_view(
icon="code_blocks",
title="IDE",
widget=self.developer_view,
id="developer_view",
exclusive=True,
)
# TODO temporary disable until the bugs with BECShell are resolved
# self.add_view(
# icon="code_blocks",
# title="IDE",
# widget=self.developer_view,
# mini_text="IDE",
# exclusive=True,
# )
if self._show_examples:
self.add_section("Examples", "examples")
waveform_view_popup = WaveformViewPopup(
parent=self, id="waveform_view_popup", title="Waveform Plot"
parent=self, view_id="waveform_view_popup", title="Waveform Plot"
)
waveform_view_stack = WaveformViewInline(
parent=self, id="waveform_view_stack", title="Waveform Plot"
parent=self, view_id="waveform_view_stack", title="Waveform Plot"
)
self.add_view(
icon="show_chart",
title="Waveform With Popup",
id="waveform_popup",
widget=waveform_view_popup,
mini_text="Popup",
)
self.add_view(
icon="show_chart",
title="Waveform InLine Stack",
id="waveform_stack",
widget=waveform_view_stack,
mini_text="Stack",
)
@@ -95,6 +104,9 @@ class BECMainApp(BECMainWindow):
self.set_current("dock_area")
self.sidebar.add_dark_mode_item()
# Add guided tour to Help menu
self._add_guided_tour_to_menu()
# --- Public API ------------------------------------------------------
def add_section(self, title: str, id: str, position: int | None = None):
return self.sidebar.add_section(title, id, position)
@@ -110,7 +122,7 @@ class BECMainApp(BECMainWindow):
*,
icon: str,
title: str,
id: str,
view_id: str | None = None,
widget: QWidget,
mini_text: str | None = None,
position: int | None = None,
@@ -124,7 +136,8 @@ class BECMainApp(BECMainWindow):
Args:
icon(str): Icon name for the nav item.
title(str): Title for the nav item.
id(str): Unique ID for the view/item.
view_id(str, optional): Unique ID for the view/item. If omitted, uses mini_text;
if mini_text is also omitted, uses title.
widget(QWidget): The widget to add to the stack.
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
position(int, optional): Position to insert the nav item.
@@ -137,10 +150,11 @@ class BECMainApp(BECMainWindow):
"""
resolved_id = sanitize_namespace(view_id or mini_text or title)
item = self.sidebar.add_item(
icon=icon,
title=title,
id=id,
id=resolved_id,
mini_text=mini_text,
position=position,
from_top=from_top,
@@ -150,13 +164,15 @@ class BECMainApp(BECMainWindow):
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
if isinstance(widget, ViewBase):
view_widget = widget
view_widget.view_id = id
view_widget.view_id = resolved_id
view_widget.view_title = title
else:
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
view_widget = ViewBase(content=widget, parent=self, view_id=resolved_id, title=title)
view_widget.change_object_name(resolved_id)
idx = self.stack.addWidget(view_widget)
self._view_index[id] = idx
self._view_index[resolved_id] = idx
return item
def set_current(self, id: str) -> None:
@@ -190,6 +206,160 @@ class BECMainApp(BECMainWindow):
if hasattr(new_view, "on_enter"):
new_view.on_enter()
def _setup_guided_tour(self):
"""
Setup the guided tour for the main application.
Registers key UI components and delegates to views for their internal components.
"""
tour_steps = []
# --- General Layout Components ---
# Register the sidebar toggle button
toggle_step = self.guided_tour.register_widget(
widget=self.sidebar.toggle,
title="Sidebar Toggle",
text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.",
)
tour_steps.append(toggle_step)
# Register the sidebar icons
sidebar_dock_area = self.sidebar.components.get("dock_area")
if sidebar_dock_area:
dock_step = self.guided_tour.register_widget(
widget=sidebar_dock_area,
title="Dock Area View",
text="Click here to access the Dock Area view, where you can manage and arrange your dockable panels.",
)
tour_steps.append(dock_step)
sidebar_device_manager = self.sidebar.components.get("device_manager")
if sidebar_device_manager:
device_manager_step = self.guided_tour.register_widget(
widget=sidebar_device_manager,
title="Device Manager View",
text="Click here to open the Device Manager view, where you can view and manage device configs.",
)
tour_steps.append(device_manager_step)
sidebar_developer_view = self.sidebar.components.get("developer_view")
if sidebar_developer_view:
developer_view_step = self.guided_tour.register_widget(
widget=sidebar_developer_view,
title="Developer View",
text="Click here to access the Developer view to write scripts and makros.",
)
tour_steps.append(developer_view_step)
# Register the dark mode toggle
dark_mode_item = self.sidebar.components.get("dark_mode")
if dark_mode_item:
dark_mode_step = self.guided_tour.register_widget(
widget=dark_mode_item,
title="Theme Toggle",
text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.",
)
tour_steps.append(dark_mode_step)
# Register the client info label
if hasattr(self, "_client_info_hover"):
client_info_step = self.guided_tour.register_widget(
widget=self._client_info_hover,
title="Client Status",
text="Displays status messages and information from the BEC Server.",
)
tour_steps.append(client_info_step)
# Register the scan progress bar if available
if hasattr(self, "_scan_progress_hover"):
progress_step = self.guided_tour.register_widget(
widget=self._scan_progress_hover,
title="Scan Progress",
text="Monitor the progress of ongoing scans. Hover over the progress bar to see detailed information including elapsed time and estimated completion.",
)
tour_steps.append(progress_step)
# Register the notification indicator in the status bar
if hasattr(self, "notification_indicator"):
notif_step = self.guided_tour.register_widget(
widget=self.notification_indicator,
title="Notification Center",
text="View system notifications, errors, and status updates. Click to filter notifications by type or expand to see all details.",
)
tour_steps.append(notif_step)
# --- View-Specific Components ---
# Register all views that can extend the tour
for view_id, view_index in self._view_index.items():
view_widget = self.stack.widget(view_index)
if not view_widget or not hasattr(view_widget, "register_tour_steps"):
continue
# Get the view's tour steps
view_tour = view_widget.register_tour_steps(self.guided_tour, self)
if view_tour is None:
if hasattr(view_widget.content, "register_tour_steps"):
view_tour = view_widget.content.register_tour_steps(self.guided_tour, self)
if view_tour is None:
continue
# Get the corresponding sidebar navigation item
nav_item = self.sidebar.components.get(view_id)
if not nav_item:
continue
# Use the view's title for the navigation button
nav_step = self.guided_tour.register_widget(
widget=nav_item,
title=view_tour.view_title,
text=f"Let's explore the features of the {view_tour.view_title}.",
)
tour_steps.append(nav_step)
tour_steps.extend(view_tour.step_ids)
# Create the tour with all registered steps
if tour_steps:
self.guided_tour.create_tour(tour_steps)
def start_guided_tour(self):
"""
Public method to start the guided tour.
This can be called programmatically or connected to a menu/button action.
"""
self.guided_tour.start_tour()
def _add_guided_tour_to_menu(self):
"""
Add a 'Guided Tour' action to the Help menu.
"""
# Find the Help menu
menu_bar = self.menuBar()
help_menu = None
for action in menu_bar.actions():
if action.text() == "Help":
help_menu = action.menu()
break
if help_menu:
# Add separator before the tour action
help_menu.addSeparator()
# Create and add the guided tour action
tour_action = QAction("Start Guided Tour", self)
tour_action.setIcon(material_icon("help"))
tour_action.triggered.connect(self.start_guided_tour)
tour_action.setShortcut("F1") # Add keyboard shortcut
help_menu.addAction(tour_action)
def cleanup(self):
for view_id, idx in self._view_index.items():
view = self.stack.widget(idx)
view.close()
view.deleteLater()
super().cleanup()
def main(): # pragma: no cover
"""
@@ -211,25 +381,12 @@ def main(): # pragma: no cover
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
w.resize(width, height)
# Center the window on the screen
x = screen_geometry.x() + (screen_geometry.width() - width) // 2
y = screen_geometry.y() + (screen_geometry.height() - height) // 2
w.move(x, y)
screen_geometry = available_screen_geometry()
if screen_geometry is not None:
width, height = main_app_size_for_screen(screen_geometry)
apply_centered_size(w, width, height, available=screen_geometry)
else:
w.resize(w.minimumSizeHint())
w.show()

View File

@@ -127,12 +127,10 @@ class NavigationItem(QWidget):
self._icon_size_expanded = QtCore.QSize(26, 26)
self.icon_btn.setIconSize(self._icon_size_collapsed)
# Remove QToolButton hover/pressed background/outline
self.icon_btn.setStyleSheet(
"""
self.icon_btn.setStyleSheet("""
QToolButton:hover { background: transparent; border: none; }
QToolButton:pressed { background: transparent; border: none; }
"""
)
""")
# Mini label below icon
self.mini_lbl = QLabel(self._mini_text, self)

View File

@@ -1,7 +1,7 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
class DeveloperView(ViewBase):
@@ -14,13 +14,89 @@ class DeveloperView(ViewBase):
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
view_id: str | None = None,
title: str | None = None,
**kwargs,
):
super().__init__(parent=parent, content=content, id=id, title=title)
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
self.developer_widget = DeveloperWidget(parent=self)
self.set_content(self.developer_widget)
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
"""Register Developer View components with the guided tour.
Args:
guided_tour: The GuidedTour instance to register with.
main_app: The main application instance (for accessing set_current).
Returns:
ViewTourSteps | None: Model containing view title and step IDs.
"""
step_ids = []
dev_widget = self.developer_widget
# IDE Toolbar
def get_ide_toolbar():
main_app.set_current("developer_view")
return (dev_widget.toolbar, None)
step_id = guided_tour.register_widget(
widget=get_ide_toolbar,
title="IDE Toolbar",
text="Quick access to save files, execute scripts, and configure IDE settings. Use the toolbar to manage your code and execution.",
)
step_ids.append(step_id)
# IDE Explorer
def get_ide_explorer():
main_app.set_current("developer_view")
return (dev_widget.explorer_dock.widget(), None)
step_id = guided_tour.register_widget(
widget=get_ide_explorer,
title="File Explorer",
text="Browse and manage your macro files. Create new files, open existing ones, and organize your scripts.",
)
step_ids.append(step_id)
# IDE Editor
def get_ide_editor():
main_app.set_current("developer_view")
return (dev_widget.monaco_dock.widget(), None)
step_id = guided_tour.register_widget(
widget=get_ide_editor,
title="Code Editor",
text="Write and edit Python code with syntax highlighting, auto-completion, and signature help. Monaco editor provides a modern coding experience.",
)
step_ids.append(step_id)
# IDE Console
def get_ide_console():
main_app.set_current("developer_view")
return (dev_widget.console_dock.widget(), None)
step_id = guided_tour.register_widget(
widget=get_ide_console,
title="BEC Shell Console",
text="Interactive Python console with BEC integration. Execute commands, test code snippets, and interact with the BEC system in real-time.",
)
step_ids.append(step_id)
# IDE Plotting Area
def get_ide_plotting():
main_app.set_current("developer_view")
return (dev_widget.plotting_ads, None)
step_id = guided_tour.register_widget(
widget=get_ide_plotting,
title="Plotting Area",
text="View plots and visualizations generated by your scripts. Arrange multiple plots in a flexible layout.",
)
step_ids.append(step_id)
return ViewTourSteps(view_title="Developer View", step_ids=step_ids)
if __name__ == "__main__":
import sys
@@ -50,7 +126,11 @@ if __name__ == "__main__":
_app.resize(width, height)
developer_view = DeveloperView()
_app.add_view(
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
icon="code_blocks",
title="IDE",
widget=developer_view,
view_id="developer_view",
exclusive=True,
)
_app.show()
# developer_view.show()

View File

@@ -13,8 +13,8 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
@@ -79,6 +79,8 @@ def markdown_to_html(md_text: str) -> str:
class DeveloperWidget(DockAreaWidget):
RPC = False
PLUGIN = False
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, variant="compact", **kwargs)
@@ -92,14 +94,14 @@ class DeveloperWidget(DockAreaWidget):
self.explorer = IDEExplorer(self)
self.explorer.setObjectName("Explorer")
self.console = BECShell(self)
self.console = BECShell(self, rpc_exposed=False)
self.console.setObjectName("BEC Shell")
self.terminal = WebConsole(self)
self.terminal = WebConsole(self, rpc_exposed=False)
self.terminal.setObjectName("Terminal")
self.monaco = MonacoDock(self)
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
self.monaco.setObjectName("MonacoEditor")
self.monaco.save_enabled.connect(self._on_save_enabled_update)
self.plotting_ads = AdvancedDockArea(
self.plotting_ads = BECDockArea(
self,
mode="plot",
default_add_direction="bottom",

View File

@@ -31,7 +31,7 @@ logger = bec_logger.logger
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
"""Popup dialog to test Ophyd device configurations interactively."""
def __init__(self, parent=None, config: dict | None = None): # type:ignore
def __init__(self, parent=None, config: dict | None = None): # type: ignore
super().__init__(parent)
self.setWindowTitle("Device Manager Ophyd Test")
self._config_status = ConfigStatus.UNKNOWN.value
@@ -133,7 +133,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
# validated: config_status, connection_status
accepted_data = QtCore.Signal(dict, int, int, str, str)
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type: ignore
super().__init__(parent)
# Track old device name if config is edited
self._old_device_name: str = ""

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from enum import IntEnum
from functools import partial
from typing import TYPE_CHECKING, List, Tuple
from typing import TYPE_CHECKING, Any, List, Tuple
from bec_lib.logger import bec_logger
from bec_qthemes import apply_theme, material_icon

View File

@@ -38,7 +38,7 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.control.device_manager.components import (
DeviceTable,
DMConfigView,
@@ -103,16 +103,14 @@ class CustomBusyWidget(QWidget):
button_width = int(button_height * aspect_ratio)
self.cancel_button.setFixedSize(button_width, button_height)
color = get_accent_colors()
self.cancel_button.setStyleSheet(
f"""
self.cancel_button.setStyleSheet(f"""
QPushButton {{
background-color: {color.emergency.name()};
color: white;
font-weight: 600;
border-radius: 6px;
}}
"""
)
""")
# Layout
content_layout = QVBoxLayout(self)
@@ -128,12 +126,10 @@ class CustomBusyWidget(QWidget):
bg_color = color._colors.get("BG", None)
if bg_color is None: # Fallback if missing
bg_color = QColor(50, 50, 50, 255)
self.setStyleSheet(
f"""
self.setStyleSheet(f"""
background-color: {bg_color.name()};
border-radius: 12px;
"""
)
""")
def _ui_scale(self) -> int:
parent = self.parent()

View File

@@ -1,11 +1,12 @@
"""Module for Device Manager View."""
from qtpy.QtCore import QRect
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
from bec_widgets.utils.error_popups import SafeSlot
@@ -19,11 +20,21 @@ class DeviceManagerView(ViewBase):
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
view_id: str | None = None,
title: str | None = None,
**kwargs,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.device_manager_widget = DeviceManagerWidget(parent=self)
super().__init__(
parent=parent,
content=content,
view_id=view_id,
title=title,
rpc_passthrough_children=False,
**kwargs,
)
self.device_manager_widget = DeviceManagerWidget(
parent=self, rpc_exposed=False, rpc_passthrough_children=False
)
self.set_content(self.device_manager_widget)
@SafeSlot()
@@ -34,6 +45,110 @@ class DeviceManagerView(ViewBase):
"""
self.device_manager_widget.on_enter()
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
"""Register Device Manager components with the guided tour.
Args:
guided_tour: The GuidedTour instance to register with.
main_app: The main application instance (for accessing set_current).
Returns:
ViewTourSteps | None: Model containing view title and step IDs.
"""
step_ids = []
dm_widget = self.device_manager_widget
# The device_manager_widget is not yet initialized, so we will register
# tour steps for its uninitialized state.
# Register Load Current Config button
def get_load_current():
main_app.set_current("device_manager")
if dm_widget._initialized is True:
return (None, None)
return (dm_widget.button_load_current_config, None)
step_id = guided_tour.register_widget(
widget=get_load_current,
title="Load Current Config",
text="Load the current device configuration from the BEC server.",
)
step_ids.append(step_id)
# Register Load Config From File button
def get_load_file():
main_app.set_current("device_manager")
if dm_widget._initialized is True:
return (None, None)
return (dm_widget.button_load_config_from_file, None)
step_id = guided_tour.register_widget(
widget=get_load_file,
title="Load Config From File",
text="Load a device configuration from a YAML file on disk.",
)
step_ids.append(step_id)
## Register steps for the initialized state
# Register main device table
def get_device_table():
main_app.set_current("device_manager")
if dm_widget._initialized is False:
return (None, None)
return (dm_widget.device_manager_display.device_table_view, None)
step_id = guided_tour.register_widget(
widget=get_device_table,
title="Device Table",
text="This table displays the config that is prepared to be uploaded to the BEC server. It allows users to review and modify device config settings, and also validate them before uploading to the BEC server.",
)
step_ids.append(step_id)
col_text_mapping = {
0: "Shows if a device configuration is valid. Automatically validated when adding a new device.",
1: "Shows if a device is connectable. Validated on demand.",
2: "Device name, unique across all devices within a config.",
3: "Device class used to initialize the device on the BEC server.",
4: "Defines how BEC treats readings of the device during scans. The options are 'monitored', 'baseline', 'async', 'continuous' or 'on_demand'.",
5: "Defines how BEC reacts if a device readback fails. Options are 'raise', 'retry', or 'buffer'.",
6: "User-defined tags associated with the device.",
7: "A brief description of the device.",
8: "Device is enabled when the configuration is loaded.",
9: "Device is set to read-only.",
10: "This flag allows to configure if the 'trigger' method of the device is called during scans.",
}
# We have at least one device registered
def get_device_table_row(column: int):
main_app.set_current("device_manager")
if dm_widget._initialized is False:
return (None, None)
table = dm_widget.device_manager_display.device_table_view.table
header = table.horizontalHeader()
x = header.sectionViewportPosition(column)
table.horizontalScrollBar().setValue(x)
# Recompute after scrolling
x = header.sectionViewportPosition(column)
w = header.sectionSize(column)
h = header.height()
rect = QRect(x, 0, w, h)
top_left = header.viewport().mapTo(main_app, rect.topLeft())
return (QRect(top_left, rect.size()), col_text_mapping.get(column, ""))
for col, text in col_text_mapping.items():
step_id = guided_tour.register_widget(
widget=lambda col=col: get_device_table_row(col),
title=f"{dm_widget.device_manager_display.device_table_view.table.horizontalHeaderItem(col).text()}",
text=text,
)
step_ids.append(step_id)
if not step_ids:
return None
return ViewTourSteps(view_title="Device Manager", step_ids=step_ids)
if __name__ == "__main__": # pragma: no cover
import sys
@@ -65,7 +180,7 @@ if __name__ == "__main__": # pragma: no cover
_app.add_view(
icon="display_settings",
title="Device Manager",
id="device_manager",
view_id="device_manager",
widget=device_manager_view.device_manager_widget,
mini_text="DM",
)

View File

@@ -22,8 +22,8 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
RPC = False
def __init__(self, parent=None, client=None):
super().__init__(parent=parent, client=client)
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)

View File

@@ -0,0 +1,31 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
class DockAreaView(ViewBase):
"""
Modular dock area view for arranging and managing multiple dockable widgets.
"""
RPC_CONTENT_CLASS = BECDockArea
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
view_id: str | None = None,
title: str | None = None,
**kwargs,
):
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
self.dock_area = BECDockArea(
self,
profile_namespace="bec",
auto_profile_namespace=False,
object_name="DockArea",
rpc_exposed=False,
)
self.set_content(self.dock_area)

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from typing import List
from pydantic import BaseModel
from qtpy.QtCore import QEventLoop
from qtpy.QtWidgets import (
QDialog,
@@ -14,13 +17,26 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class ViewBase(QWidget):
class ViewTourSteps(BaseModel):
"""Model representing tour steps for a view.
Attributes:
view_title: The human-readable title of the view.
step_ids: List of registered step IDs in the order they should appear.
"""
view_title: str
step_ids: List[str]
class ViewBase(BECWidget, QWidget):
"""Wrapper for a content widget used inside the main app's stacked view.
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
@@ -28,21 +44,28 @@ class ViewBase(QWidget):
Args:
content (QWidget): The actual view widget to display.
parent (QWidget | None): Parent widget.
id (str | None): Optional view id, useful for debugging or introspection.
view_id (str | None): Optional view view_id, useful for debugging or introspection.
title (str | None): Optional human-readable title.
"""
RPC = True
PLUGIN = False
USER_ACCESS = ["activate"]
RPC_CONTENT_CLASS: type[QWidget] | None = None
RPC_CONTENT_ATTR = "content"
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
view_id: str | None = None,
title: str | None = None,
**kwargs,
):
super().__init__(parent=parent)
super().__init__(parent=parent, **kwargs)
self.content: QWidget | None = None
self.view_id = id
self.view_id = view_id
self.view_title = title
lay = QVBoxLayout(self)
@@ -76,6 +99,41 @@ class ViewBase(QWidget):
"""
return True
@SafeSlot()
def activate(self) -> None:
"""Switch the parent application to this view."""
if not self.view_id:
raise ValueError("Cannot switch view without a view_id.")
parent = self.parent()
while parent is not None:
if hasattr(parent, "set_current"):
parent.set_current(self.view_id)
return
parent = parent.parent()
raise RuntimeError("Could not find a parent application with set_current().")
def cleanup(self):
if self.content is not None:
self.content.close()
self.content.deleteLater()
super().cleanup()
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
"""Register this view's components with the guided tour.
Args:
guided_tour: The GuidedTour instance to register with.
main_app: The main application instance (for accessing set_current).
Returns:
ViewTourSteps | None: A model containing the view title and step IDs,
or None if this view has no tour steps.
Override this method in subclasses to register view-specific components.
"""
return None
####################################################################################################
# Example views for demonstration/testing purposes
@@ -102,17 +160,17 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
self.signal_edit = SignalComboBox(parent=self)
self.signal_edit.include_config_signals = False
self.signal_edit.insertItem(0, "")
self.signal_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.signal_edit.set_device)
self.device_edit.device_reset.connect(self.signal_edit.reset_selection)
form = QFormLayout()
form.addRow(label)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
form.addRow("Signal", self.signal_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
buttons.accepted.connect(dialog.accept)
@@ -124,7 +182,7 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
if dialog.exec_() == QDialog.Accepted:
self.waveform.plot(
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
device_y=self.device_edit.currentText(), signal_y=self.signal_edit.currentText()
)
@SafeSlot()
@@ -249,7 +307,7 @@ class WaveformViewInline(ViewBase): # pragma: no cover
dev = self.device_edit.currentText()
sig = self.entry_edit.currentText()
if dev and sig:
self.waveform.plot(y_name=dev, y_entry=sig)
self.waveform.plot(device_y=dev, signal_y=sig)
self.stack.setCurrentIndex(1)
def _show_waveform_without_changes(self):

File diff suppressed because it is too large Load Diff

View File

@@ -297,14 +297,32 @@ class BECGuiClient(RPCBase):
return self._raise_all()
return self._start(wait=wait)
def change_theme(self, theme: Literal["light", "dark"] | None = None) -> None:
"""
Apply a GUI theme or toggle between dark and light.
Args:
theme(Literal["light", "dark"] | None): Theme to apply. If None, the current
theme is fetched from the GUI and toggled.
"""
if not self._check_if_server_is_alive():
self._start(wait=True)
with wait_for_server(self):
if theme is None:
current_theme = self.launcher._run_rpc("fetch_theme")
next_theme = "light" if current_theme == "dark" else "dark"
else:
next_theme = theme
self.launcher._run_rpc("change_theme", theme=next_theme)
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
profile: str | None = None,
start_empty: bool = False,
startup_profile: str | Literal["restore", "skip"] | None = None,
**kwargs,
) -> client.AdvancedDockArea:
"""Create a new top-level dock area.
@@ -314,48 +332,81 @@ class BECGuiClient(RPCBase):
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
launch_script(str): The launch script to use. Defaults to "dock_area".
profile(str | None): The profile name to load. If None, loads the "general" profile.
Use a profile name to load a specific saved profile.
start_empty(bool): If True, start with an empty dock area when loading specified profile.
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
the dock area:
- None: start in transient empty workspace
- "restore": restore last-used profile
- "skip": skip profile initialization
- "<name>": load the named profile
**kwargs: Additional keyword arguments passed to the dock area.
Returns:
client.AdvancedDockArea: The new dock area.
Note:
The "general" profile is mandatory and will always exist. If manually deleted,
it will be automatically recreated.
Examples:
>>> gui.new() # Start with the "general" profile
>>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name
>>> gui.new(start_empty=True) # Start with "general" profile but empty dock area
>>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area
>>> gui.new() # Start with an empty unsaved workspace
>>> gui.new(startup_profile="restore") # Restore last profile
>>> gui.new(startup_profile="my_profile") # Load explicit profile
"""
if "profile" in kwargs or "start_empty" in kwargs:
raise TypeError(
"gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead."
)
if not self._check_if_server_is_alive():
self.start(wait=True)
if wait:
with wait_for_server(self):
widget = self.launcher._run_rpc(
"launch",
launch_script=launch_script,
return self._new_impl(
name=name,
geometry=geometry,
profile=profile,
start_empty=start_empty,
launch_script=launch_script,
startup_profile=startup_profile,
**kwargs,
) # pylint: disable=protected-access
return widget
widget = self.launcher._run_rpc(
)
return self._new_impl(
name=name,
geometry=geometry,
launch_script=launch_script,
startup_profile=startup_profile,
**kwargs,
)
def _new_impl(
self,
*,
name: str | None,
geometry: tuple[int, int, int, int] | None,
launch_script: str,
startup_profile: str | Literal["restore", "skip"] | None,
**kwargs,
):
if launch_script == "dock_area":
try:
return self.launcher._run_rpc(
"system.launch_dock_area",
name=name,
geometry=geometry,
startup_profile=startup_profile,
**kwargs,
)
except ValueError as exc:
error = str(exc)
if (
"Unknown system RPC method: system.launch_dock_area" not in error
and "has no attribute 'system.launch_dock_area'" not in error
):
raise
logger.debug("Server does not support system.launch_dock_area; using launcher RPC")
return self.launcher._run_rpc(
"launch",
launch_script=launch_script,
name=name,
geometry=geometry,
profile=profile,
start_empty=start_empty,
startup_profile=startup_profile,
**kwargs,
) # pylint: disable=protected-access
return widget
def delete(self, name: str) -> None:
"""Delete a dock area and its parent window.

View File

@@ -164,17 +164,13 @@ class {class_name}(RPCBase):"""
self.content += f"""
\"\"\"{class_docs}\"\"\"
"""
if not cls.USER_ACCESS:
user_access_entries = self._get_user_access_entries(cls)
if not user_access_entries:
self.content += """...
"""
for method in cls.USER_ACCESS:
is_property_setter = False
obj = getattr(cls, method, None)
if obj is None:
obj = getattr(cls, method.split(".setter")[0], None)
is_property_setter = True
method = method.split(".setter")[0]
for method_entry in user_access_entries:
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
if obj is None:
raise AttributeError(
f"Method {method} not found in class {cls.__name__}. "
@@ -216,6 +212,34 @@ class {class_name}(RPCBase):"""
{doc}
\"\"\""""
@staticmethod
def _get_user_access_entries(cls) -> list[str]:
entries = list(getattr(cls, "USER_ACCESS", []))
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
if content_cls is not None:
entries.extend(getattr(content_cls, "USER_ACCESS", []))
return list(dict.fromkeys(entries))
@staticmethod
def _resolve_method_object(cls, method_entry: str):
method_name = method_entry
is_property_setter = False
if method_entry.endswith(".setter"):
is_property_setter = True
method_name = method_entry.split(".setter")[0]
candidate_classes = [cls]
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
if content_cls is not None:
candidate_classes.append(content_cls)
for candidate_cls in candidate_classes:
obj = getattr(candidate_cls, method_name, None)
if obj is not None:
return method_name, obj, is_property_setter
return method_name, None, is_property_setter
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
@@ -291,7 +315,8 @@ def main():
client_path = module_dir / client_subdir / "client.py"
rpc_classes = get_custom_classes(module_name)
packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",)
rpc_classes = get_custom_classes(module_name, packages=packages)
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
generator = ClientGenerator(base=module_name == "bec_widgets")

View File

@@ -292,6 +292,11 @@ class RPCBase:
return {
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
}
rpc_enabled = msg_result.get("__rpc__", True)
if rpc_enabled is False:
return None
msg_result = dict(msg_result)
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)

View File

@@ -32,7 +32,8 @@ class RPCWidgetHandler:
None
"""
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
+ get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:

View File

@@ -8,6 +8,7 @@ import sys
from contextlib import redirect_stderr, redirect_stdout
import darkdetect
import shiboken6
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_qthemes import apply_theme
@@ -93,6 +94,7 @@ class GUIServer:
"""
Run the GUI server.
"""
logger.info("Starting GUIServer", repr(self))
self.app = QApplication(sys.argv)
if darkdetect.isDark():
apply_theme("dark")
@@ -101,11 +103,11 @@ class GUIServer:
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.app.gui_server = self # type: ignore # make server accessible from QApplication for getattr in widgets
self.setup_bec_icon()
service_config = self._get_service_config()
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
if self.gui_class:
self.launcher_window = LaunchWindow(
@@ -118,7 +120,7 @@ class GUIServer:
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
self.app.setQuitOnLastWindowClosed(True)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
@@ -127,8 +129,7 @@ class GUIServer:
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
widget.close()
if self.app:
self.app.quit()
self.shutdown()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
@@ -149,9 +150,10 @@ class GUIServer:
self.app.setWindowIcon(icon)
def shutdown(self):
"""
Shutdown the GUI server.
"""
logger.info("Shutdown GUIServer", repr(self))
if self.launcher_window and shiboken6.isValid(self.launcher_window):
self.launcher_window.close()
self.launcher_window.deleteLater()
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:

View File

@@ -12,7 +12,7 @@ import shiboken6 as shb
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -23,7 +23,6 @@ from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, s
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.dock import BECDock
else:
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -89,6 +88,8 @@ class BECConnector:
gui_id: str | None = None,
object_name: str | None = None,
root_widget: bool = False,
rpc_exposed: bool = True,
rpc_passthrough_children: bool = True,
**kwargs,
):
"""
@@ -100,6 +101,10 @@ class BECConnector:
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
rpc_exposed(bool, optional): If set to False, this instance is excluded from RPC registry broadcast and CLI namespace discovery.
rpc_passthrough_children(bool, optional): Only relevant when ``rpc_exposed=False``.
If True, RPC-visible children rebind to the next visible ancestor.
If False (default), children stay hidden behind this widget.
**kwargs:
"""
# Extract object_name from kwargs to not pass it to Qt class
@@ -128,8 +133,13 @@ class BECConnector:
# the function depends on BECClient, and BECDispatcher
@SafeSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
app = QApplication.instance()
gui_server = getattr(app, "gui_server", None)
if gui_server and hasattr(gui_server, "shutdown"):
gui_server.shutdown()
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
dispatcher.stop_cli_server()
try: # shutdown ophyd threads if any
from ophyd._pyepics_shim import _dispatcher
@@ -185,6 +195,11 @@ class BECConnector:
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
# If set to False, this instance is not exposed through RPC at all.
self.rpc_exposed = bool(rpc_exposed)
# If True on a hidden parent (rpc_exposed=False), children can bubble up to
# the next visible RPC ancestor.
self.rpc_passthrough_children = bool(rpc_passthrough_children)
self._update_object_name()
@@ -193,11 +208,41 @@ class BECConnector:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
connector_parent = self._get_rpc_parent_ancestor()
return connector_parent.gui_id if connector_parent else None
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
def _get_rpc_parent_ancestor(self) -> BECConnector | None:
"""
Find the nearest ancestor that is RPC-addressable.
Rules:
- If an ancestor has ``rpc_exposed=False``, it is an explicit visibility
boundary unless ``rpc_passthrough_children=True``.
- If an ancestor has ``RPC=False`` (but remains rpc_exposed), it is treated
as structural and children continue to the next ancestor.
- Lookup always happens through ``WidgetHierarchy.get_becwidget_ancestor``
so plain ``QWidget`` nodes between connectors are ignored.
"""
current = self
while True:
parent = WidgetHierarchy.get_becwidget_ancestor(current)
if parent is None:
return None
if not getattr(parent, "rpc_exposed", True):
if getattr(parent, "rpc_passthrough_children", False):
current = parent
continue
return parent
if getattr(parent, "RPC", True):
return parent
current = parent
return None
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
@@ -216,8 +261,9 @@ class BECConnector:
"""
# 1) Enforce unique objectName among siblings with the same BECConnector parent
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
# 2) Register the object for RPC unless instance-level exposure is disabled.
if getattr(self, "rpc_exposed", True):
self.rpc_register.add_rpc(self)
try:
self.name_established.emit(self.object_name)
except RuntimeError as e:
@@ -235,7 +281,7 @@ class BECConnector:
if not shb.isValid(self):
return
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
parent_bec = WidgetHierarchy.get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
@@ -245,7 +291,7 @@ class BECConnector:
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
connections = self.rpc_register.list_all_connections().values()
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
siblings = [w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None]
siblings = [w for w in all_bec if WidgetHierarchy.get_becwidget_ancestor(w) is None]
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
@@ -273,6 +319,8 @@ class BECConnector:
Args:
name (str): The new object name.
"""
# sanitize before setting to avoid issues with Qt object names and RPC namespaces
name = sanitize_namespace(name)
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):

View File

@@ -0,0 +1,87 @@
"""
Login dialog for user authentication.
The Login Widget is styled in a Material Design style and emits
the entered credentials through a signal for further processing.
"""
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
class BECLogin(QWidget):
"""Login dialog for user authentication in Material Design style."""
credentials_entered = Signal(str, str)
def __init__(self, parent=None):
super().__init__(parent=parent)
# Only displayed if this widget as standalone widget, and not embedded in another widget
self.setWindowTitle("Login")
title = QLabel("Sign in", parent=self)
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("""
#QLabel
{
font-size: 18px;
font-weight: 600;
}
""")
self.username = QLineEdit(parent=self)
self.username.setPlaceholderText("Username")
self.password = QLineEdit(parent=self)
self.password.setPlaceholderText("Password")
self.password.setEchoMode(QLineEdit.EchoMode.Password)
self.ok_btn = QPushButton("Sign in", parent=self)
self.ok_btn.setDefault(True)
self.ok_btn.clicked.connect(self._emit_credentials)
# If the user presses Enter in the password field, trigger the OK button click
self.password.returnPressed.connect(self.ok_btn.click)
# Build Layout
layout = QVBoxLayout(self)
layout.setContentsMargins(32, 32, 32, 32)
layout.setSpacing(16)
layout.addWidget(title)
layout.addSpacing(8)
layout.addWidget(self.username)
layout.addWidget(self.password)
layout.addSpacing(12)
layout.addWidget(self.ok_btn)
self.username.setFocus()
self.setStyleSheet("""
QLineEdit {
padding: 8px;
}
""")
def _clear_password(self):
"""Clear the password field."""
self.password.clear()
def _emit_credentials(self):
"""Emit credentials and clear the password field."""
self.credentials_entered.emit(self.username.text().strip(), self.password.text())
self._clear_password()
if __name__ == "__main__": # pragma: no cover
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("light")
dialog = BECLogin()
dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}"))
dialog.show()
sys.exit(app.exec_())

View File

@@ -101,8 +101,13 @@ class Colors:
return requested
# Case-insensitive match.
lower_to_canonical = {name.lower(): name for name in available}
return lower_to_canonical.get(requested.lower(), requested)
requested_lc = requested.casefold()
for name in available:
if name.casefold() == requested_lc:
return name
return requested
@staticmethod
def get_colormap(color_map: str) -> pg.ColorMap:

View File

@@ -106,7 +106,9 @@ class TypedForm(BECWidget, QWidget):
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(parent=self._form_grid, text=item.name)
# Use title from FieldInfo if available, otherwise use the property name
label_text = item.info.title if item.info.title else item.name
label = QLabel(parent=self._form_grid, text=label_text)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)

View File

@@ -231,6 +231,8 @@ class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
if spec.info.description:
self._main_widget.setPlaceholderText(spec.info.description)
def _add_main_widget(self) -> None:
self._main_widget = QLineEdit()

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import sys
import weakref
from typing import Callable, Dict, List, TypedDict
from typing import Callable, Dict, List, Literal, TypedDict
from uuid import uuid4
import louie
@@ -12,15 +12,18 @@ from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from louie import saferef
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
from qtpy.QtGui import QAction, QColor, QPainter, QPen
from qtpy.QtGui import QAction, QColor, QKeySequence, QPainter, QPen, QShortcut
from qtpy.QtWidgets import (
QAbstractItemView,
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QMenuBar,
QPushButton,
QTableWidgetItem,
QToolBar,
QVBoxLayout,
QWidget,
@@ -40,9 +43,9 @@ class TourStep(TypedDict):
widget_ref: (
louie.saferef.BoundMethodWeakref
| weakref.ReferenceType[
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
]
| Callable[[], tuple[QWidget | QAction, str | None]]
| Callable[[], tuple[QWidget | QAction | QRect, str | None]]
| None
)
text: str
@@ -64,15 +67,13 @@ class TutorialOverlay(QWidget):
box = QFrame(self)
app = QApplication.instance()
bg_color = app.palette().window().color()
box.setStyleSheet(
f"""
box.setStyleSheet(f"""
QFrame {{
background-color: {bg_color.name()};
border-radius: 8px;
padding: 8px;
}}
"""
)
""")
layout = QVBoxLayout(box)
# Top layout with close button (left) and step indicator (right)
@@ -103,10 +104,12 @@ class TutorialOverlay(QWidget):
# Back button with material icon
self.back_btn = QPushButton("Back")
self.back_btn.setIcon(material_icon("arrow_back"))
self.back_btn.setToolTip("Press Backspace to go back")
# Next button with material icon
self.next_btn = QPushButton("Next")
self.next_btn.setIcon(material_icon("arrow_forward"))
self.next_btn.setToolTip("Press Enter to continue")
btn_layout.addStretch()
btn_layout.addWidget(self.back_btn)
@@ -115,6 +118,15 @@ class TutorialOverlay(QWidget):
layout.addLayout(top_layout)
layout.addWidget(self.label)
layout.addLayout(btn_layout)
# Escape closes the tour
QShortcut(QKeySequence(Qt.Key.Key_Escape), self, activated=self.close_btn.click)
# Enter and Return activates the next button
QShortcut(QKeySequence(Qt.Key.Key_Return), self, activated=self.next_btn.click)
QShortcut(QKeySequence(Qt.Key.Key_Enter), self, activated=self.next_btn.click)
# Map Backspace to the back button
QShortcut(QKeySequence(Qt.Key.Key_Backspace), self, activated=self.back_btn.click)
return box
def paintEvent(self, event): # pylint: disable=unused-argument
@@ -223,6 +235,9 @@ class TutorialOverlay(QWidget):
self.message_box.show()
self.update()
# Update the focus policy of the buttons
self.back_btn.setEnabled(current_step > 1)
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Resize:
self.setGeometry(obj.rect())
@@ -262,7 +277,9 @@ class GuidedTour(QObject):
def register_widget(
self,
*,
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
widget: (
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
),
text: str = "",
title: str = "",
) -> str:
@@ -270,7 +287,7 @@ class GuidedTour(QObject):
Register a widget with help text for tours.
Args:
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
widget (QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]): The target widget or a callable that returns the widget and its help text.
text (str): The help text for the widget. This will be shown during the tour.
title (str, optional): A title for the widget (defaults to its class name or action text).
@@ -293,6 +310,9 @@ class GuidedTour(QObject):
widget_ref = _resolve_toolbar_button
default_title = getattr(widget, "tooltip", "Toolbar Menu")
elif isinstance(widget, QRect):
widget_ref = widget
default_title = "Area"
else:
widget_ref = saferef.safe_ref(widget)
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
@@ -327,11 +347,14 @@ class GuidedTour(QObject):
if mb and mb not in menubars:
menubars.append(mb)
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
menubars += [mb for mb in mw.findChildren(QMenu) if mb not in menubars]
for mb in menubars:
if action in mb.actions():
ar = mb.actionGeometry(action)
top_left = mb.mapTo(mw, ar.topLeft())
return QRect(top_left, ar.size())
return None
def unregister_widget(self, step_id: str) -> bool:
@@ -452,9 +475,9 @@ class GuidedTour(QObject):
if self._current_index > 0:
self._current_index -= 1
self._show_current_step()
self._show_current_step(direction="prev")
def _show_current_step(self):
def _show_current_step(self, direction: Literal["next"] | Literal["prev"] = "next"):
"""Display the current step."""
if not self._active or not self.overlay:
return
@@ -464,7 +487,9 @@ class GuidedTour(QObject):
target, step_text = self._resolve_step_target(step)
if target is None:
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
self._advance_past_invalid_step(
step_title, reason="Step target no longer exists.", direction=direction
)
return
main_window = self.main_window
@@ -473,7 +498,9 @@ class GuidedTour(QObject):
self.stop_tour()
return
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
highlight_rect = self._get_highlight_rect(
main_window, target, step_title, direction=direction
)
if highlight_rect is None:
return
@@ -483,9 +510,6 @@ class GuidedTour(QObject):
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
# Update button states
self.overlay.back_btn.setEnabled(self._current_index > 0)
# Update next button text and state
is_last_step = self._current_index >= len(self._tour_steps) - 1
if is_last_step:
@@ -499,7 +523,7 @@ class GuidedTour(QObject):
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | QRect | None, str]:
"""
Resolve the target widget/action for the given step.
@@ -507,7 +531,7 @@ class GuidedTour(QObject):
step(TourStep): The tour step to resolve.
Returns:
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
tuple[QWidget | QAction | QRect | None, str]: The resolved target, optional QRect, and the step text.
"""
widget_ref = step.get("widget_ref")
step_text = step.get("text", "")
@@ -520,7 +544,7 @@ class GuidedTour(QObject):
if target is None:
return None, step_text
if callable(target) and not isinstance(target, (QWidget, QAction)):
if callable(target) and not isinstance(target, (QWidget, QAction, QRect)):
result = target()
if isinstance(result, tuple):
target, alt_text = result
@@ -532,7 +556,11 @@ class GuidedTour(QObject):
return target, step_text
def _get_highlight_rect(
self, main_window: QWidget, target: QWidget | QAction, step_title: str
self,
main_window: QWidget,
target: QWidget | QAction | QRect,
step_title: str,
direction: Literal["next"] | Literal["prev"] = "next",
) -> QRect | None:
"""
Get the QRect in main_window coordinates to highlight for the given target.
@@ -545,12 +573,15 @@ class GuidedTour(QObject):
Returns:
QRect | None: The rectangle to highlight, or None if not found/visible.
"""
if isinstance(target, QRect):
return target
if isinstance(target, QAction):
rect = self._action_highlight_rect(target)
if rect is None:
self._advance_past_invalid_step(
step_title,
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
direction=direction,
)
return None
return rect
@@ -559,28 +590,60 @@ class GuidedTour(QObject):
if self._visible_check:
if not target.isVisible():
self._advance_past_invalid_step(
step_title, reason=f"Widget {target!r} is not visible."
step_title, reason=f"Widget {target!r} is not visible.", direction=direction
)
return None
rect = target.rect()
top_left = target.mapTo(main_window, rect.topLeft())
return QRect(top_left, rect.size())
if isinstance(target, QTableWidgetItem):
# NOTE: On header items (which are also QTableWidgetItems), this does not work,
# Header items are just used as data containers by Qt, thus, we have to directly
# pass the QRect through the method (+ make sure the appropriate header section
# is visible). This can be handled in the callable method.)
table = target.tableWidget()
if self._visible_check:
if not table.isVisible():
self._advance_past_invalid_step(
step_title,
reason=f"Table widget {table!r} is not visible.",
direction=direction,
)
return None
# Table item
if table.item(target.row(), target.column()) == target:
table.scrollToItem(target, QAbstractItemView.ScrollHint.PositionAtCenter)
rect = table.visualItemRect(target)
top_left = table.viewport().mapTo(main_window, rect.topLeft())
return QRect(top_left, rect.size())
self._advance_past_invalid_step(
step_title, reason=f"Unsupported step target type: {type(target)}"
step_title, reason=f"Unsupported step target type: {type(target)}", direction=direction
)
return None
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
def _advance_past_invalid_step(
self, step_title: str, *, reason: str, direction: Literal["next"] | Literal["prev"] = "next"
):
"""
Skip the current step (or stop the tour) when the target cannot be visualised.
"""
logger.warning("%s Skipping step %r.", reason, step_title)
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
self.stop_tour()
logger.warning(f"{reason} Skipping step {step_title!r}.")
if direction == "next":
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
self.stop_tour()
elif direction == "prev":
if self._current_index > 0:
self._current_index -= 1
self._show_current_step(direction="prev")
else:
self.stop_tour()
def get_registered_widgets(self) -> Dict[str, TourStep]:
"""Get all registered widgets."""
@@ -663,8 +726,33 @@ class MainWindow(QMainWindow): # pragma: no cover
title="Tools Menu",
)
sub_menu_action = self.tools_menu_actions["notes"].action
def get_sub_menu_action():
# open the tools menu
menu_button = self.tools_menu_action._button_ref()
if menu_button:
menu_button.showMenu()
return (
self.tools_menu_action.actions["notes"].action,
"This action allows you to add notes.",
)
sub_menu = self.guided_help.register_widget(
widget=get_sub_menu_action,
text="This is a sub-action within the tools menu.",
title="Add Note Action",
)
# Create tour from registered widgets
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
self.tour_step_ids = [
sub_menu,
primary_step,
secondary_step,
toolbar_action_step,
tools_menu_step,
]
widget_ids = self.tour_step_ids
self.guided_help.create_tour(widget_ids)

View File

@@ -129,7 +129,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
if not isinstance(widget, BECWidget):
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
widget = WidgetHierarchy.get_becwidget_ancestor(widget)
if widget:
if widget is self:
self._toggle_mode(False)

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
@@ -166,18 +166,17 @@ class BECClassContainer:
return [info.obj for info in self.collection]
def get_custom_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
collection = BECClassContainer()
anchor_module = importlib.import_module(f"{repo_name}.widgets")
try:
anchor_module = importlib.import_module(f"{repo_name}.{package}")
except ModuleNotFoundError as exc:
# Some plugin repositories expose only one subtree. Skip gracefully if it does not exist.
if exc.name == f"{repo_name}.{package}":
return collection
raise
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
@@ -185,13 +184,13 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
rel_dir = os.path.dirname(os.path.relpath(path, directory))
if rel_dir in ("", "."):
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
module = importlib.import_module(f"{repo_name}.{package}.{module_name}")
for name in dir(module):
obj = getattr(module, name)
@@ -203,12 +202,30 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
return collection
def get_custom_classes(
repo_name: str, packages: tuple[str, ...] | None = None
) -> BECClassContainer:
"""
Get all relevant classes for RPC/CLI in the specified repository.
By default, discovery is limited to ``<repo>.widgets`` for backward compatibility.
Additional package subtrees (for example ``applications``) can be included explicitly.
Args:
repo_name(str): The name of the repository.
packages(tuple[str, ...] | None): Optional tuple of package names to scan. Defaults to ("widgets",) for backward compatibility.
Returns:
BECClassContainer: Container with collected class information.
"""
selected_packages = packages or ("widgets",)
collection = BECClassContainer()
for package in selected_packages:
collection += _collect_classes_from_package(repo_name, package)
return collection

View File

@@ -69,13 +69,11 @@ class RoundedFrame(QFrame):
"""
Update the style of the frame based on the background color.
"""
self.setStyleSheet(
f"""
self.setStyleSheet(f"""
QFrame#roundedFrame {{
border-radius: {self._radius}px;
}}
"""
)
""")
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):

View File

@@ -4,20 +4,24 @@ import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, TypeVar
from typing import TYPE_CHECKING, Callable, Literal, TypeVar
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QWidget
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.utils.screen_utils import apply_window_geometry
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
@@ -114,11 +118,17 @@ class RPCServer:
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
logger.info(
f"Processing RPC instruction: {msg['action']} with request_id: {request_id}. Parameters: {msg.get('parameter')}"
)
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
if method.startswith("system."):
res = self.run_system_rpc(method, args, kwargs)
else:
obj = self.get_object_from_config(msg["parameter"])
res = self.run_rpc(obj, method, args, kwargs)
except Exception:
content = traceback.format_exc()
logger.error(f"Error while executing RPC instruction: {content}")
@@ -174,18 +184,100 @@ class RPCServer:
obj.show()
res = None
else:
method_obj = getattr(obj, method)
target_obj, method_obj = self._resolve_rpc_target(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])
setattr(target_obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
return res
def _resolve_rpc_target(self, obj, method: str) -> tuple[object, object]:
"""
Resolve a method/property access target for RPC execution.
Primary target is the object itself. If not found there and the class defines
``RPC_CONTENT_CLASS``, unresolved method names can be delegated to the content
widget referenced by ``RPC_CONTENT_ATTR`` (default ``content``), but only when
the method is explicitly listed in the content class ``USER_ACCESS``.
"""
if hasattr(obj, method):
return obj, getattr(obj, method)
content_cls = getattr(type(obj), "RPC_CONTENT_CLASS", None)
if content_cls is None:
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
content_user_access = set()
for entry in getattr(content_cls, "USER_ACCESS", []):
if entry.endswith(".setter"):
content_user_access.add(entry.split(".setter")[0])
else:
content_user_access.add(entry)
if method not in content_user_access:
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
content_attr = getattr(type(obj), "RPC_CONTENT_ATTR", "content")
target_obj = getattr(obj, content_attr, None)
if target_obj is None:
raise AttributeError(
f"{type(obj).__name__} has no content target '{content_attr}' for RPC delegation"
)
if not isinstance(target_obj, content_cls):
raise AttributeError(
f"{type(obj).__name__}.{content_attr} is not instance of {content_cls.__name__}"
)
if not hasattr(target_obj, method):
raise AttributeError(f"{content_cls.__name__} has no attribute '{method}'")
return target_obj, getattr(target_obj, method)
def run_system_rpc(self, method: str, args: list, kwargs: dict):
if method == "system.launch_dock_area":
return self._launch_dock_area(*args, **kwargs)
if method == "system.list_capabilities":
return {"system.launch_dock_area": True}
raise ValueError(f"Unknown system RPC method: {method}")
@staticmethod
def _launch_dock_area(
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
startup_profile: str | Literal["restore", "skip"] | None = None,
) -> QWidget | None:
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name)
if name in existing_dock_areas:
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
else:
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
logger.info(
f"Launching new dock area with name: {name} and startup_profile: {startup_profile}"
)
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
result_widget.window().setWindowTitle(f"BEC - {name}")
if isinstance(result_widget, BECMainWindow):
apply_window_geometry(result_widget, geometry)
result_widget.show()
else:
window = BECMainWindowNoRPC()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
apply_window_geometry(window, geometry)
window.show()
return result_widget
def serialize_result_and_send(self, request_id: str, res: object):
"""
Serialize the result of an RPC call and send it back to the client.
@@ -211,6 +303,9 @@ class RPCServer:
else:
res = self.serialize_object(res)
except RegistryNotReadyError:
logger.info(
f"Object not registered yet for RPC request {request_id}, retrying serialization after {retry_delay} ms"
)
try:
self._rpc_singleshot_repeats[request_id] += retry_delay
QTimer.singleShot(
@@ -255,6 +350,9 @@ class RPCServer:
# Respect RPC = False
if getattr(obj, "RPC", True) is False:
return None
# Respect rpc_exposed = False
if getattr(obj, "rpc_exposed", True) is False:
return None
return self._serialize_bec_connector(obj, wait=True)
def emit_heartbeat(self) -> None:
@@ -283,6 +381,8 @@ class RPCServer:
continue
if not getattr(val, "RPC", True):
continue
if not getattr(val, "rpc_exposed", True):
continue
data[key] = self._serialize_bec_connector(val)
if self._broadcasted_data == data:
return
@@ -333,23 +433,9 @@ class RPCServer:
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
"__rpc__": getattr(connector, "rpc_exposed", True),
}
@staticmethod
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
# Suppose clients register callbacks to receive updates
def add_registry_update_callback(self, cb: Callable) -> None:
"""

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QApplication, QWidget
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QRect
def available_screen_geometry(*, widget: QWidget | None = None) -> QRect | None:
"""
Get the available geometry of the screen associated with the given widget or application.
Args:
widget(QWidget | None): The widget to get the screen from.
Returns:
QRect | None: The available geometry of the screen, or None if no screen is found.
"""
screen = widget.screen() if widget is not None else None
if screen is None:
app = QApplication.instance()
screen = app.primaryScreen() if app is not None else None
if screen is None:
return None
return screen.availableGeometry()
def centered_geometry(available: "QRect", width: int, height: int) -> tuple[int, int, int, int]:
"""
Calculate centered geometry within the available rectangle.
Args:
available(QRect): The available rectangle to center within.
width(int): The desired width.
height(int): The desired height.
Returns:
tuple[int, int, int, int]: The (x, y, width, height) of the centered geometry.
"""
x = available.x() + (available.width() - width) // 2
y = available.y() + (available.height() - height) // 2
return x, y, width, height
def centered_geometry_for_app(width: int, height: int) -> tuple[int, int, int, int] | None:
available = available_screen_geometry()
if available is None:
return None
return centered_geometry(available, width, height)
def scaled_centered_geometry_for_window(
window: QWidget, *, width_ratio: float = 0.8, height_ratio: float = 0.8
) -> tuple[int, int, int, int] | None:
available = available_screen_geometry(widget=window)
if available is None:
return None
width = int(available.width() * width_ratio)
height = int(available.height() * height_ratio)
return centered_geometry(available, width, height)
def apply_window_geometry(
window: QWidget,
geometry: tuple[int, int, int, int] | None,
*,
width_ratio: float = 0.8,
height_ratio: float = 0.8,
) -> None:
if geometry is not None:
window.setGeometry(*geometry)
return
default_geometry = scaled_centered_geometry_for_window(
window, width_ratio=width_ratio, height_ratio=height_ratio
)
if default_geometry is not None:
window.setGeometry(*default_geometry)
else:
window.resize(window.minimumSizeHint())
def main_app_size_for_screen(available: "QRect") -> tuple[int, int]:
height = int(available.height() * 0.9)
width = int(height * (16 / 9))
if width > available.width() * 0.9:
width = int(available.width() * 0.9)
height = int(width / (16 / 9))
return width, height
def apply_centered_size(
window: QWidget, width: int, height: int, *, available: "QRect" | None = None
) -> None:
if available is None:
available = available_screen_geometry(widget=window)
if available is None:
window.resize(width, height)
return
window.setGeometry(*centered_geometry(available, width, height))

View File

@@ -599,16 +599,14 @@ class ExpandableMenuAction(ToolBarAction):
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
button.setStyleSheet(
"""
button.setStyleSheet("""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
""")
menu = QMenu(button)
for action_container in self.actions.values():
action: QAction = action_container.action

View File

@@ -106,8 +106,7 @@ class ResizableSpacer(QWidget):
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setStyleSheet(
"""
self.setStyleSheet("""
ResizableSpacer {
background-color: transparent;
margin: 0px;
@@ -117,8 +116,7 @@ class ResizableSpacer(QWidget):
ResizableSpacer:hover {
background-color: rgba(100, 100, 200, 80);
}
"""
)
""")
self.setContentsMargins(0, 0, 0, 0)

View File

@@ -291,8 +291,7 @@ class ModularToolBar(QToolBar):
menu = QMenu(self)
theme = get_theme_name()
if theme == "dark":
menu.setStyleSheet(
"""
menu.setStyleSheet("""
QMenu {
background-color: rgba(50, 50, 50, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
@@ -300,12 +299,10 @@ class ModularToolBar(QToolBar):
QMenu::item:selected {
background-color: rgba(0, 0, 255, 0.2);
}
"""
)
""")
else:
# Light theme styling
menu.setStyleSheet(
"""
menu.setStyleSheet("""
QMenu {
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.2);
@@ -313,8 +310,7 @@ class ModularToolBar(QToolBar):
QMenu::item:selected {
background-color: rgba(0, 0, 255, 0.2);
}
"""
)
""")
for ii, bundle in enumerate(self.shown_bundles):
self.handle_bundle_context_menu(menu, bundle)
if ii < len(self.shown_bundles) - 1:

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import shiboken6
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt
from qtpy.QtWidgets import QFrame, QWidget
class WidgetHighlighter:
"""
Utility that highlights widgets by drawing a temporary frame around them.
"""
def __init__(
self,
*,
frame_parent: QWidget | None = None,
window_flags: Qt.WindowType | Qt.WindowFlags = Qt.WindowType.Tool
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
style_sheet: str = "border: 2px solid #FF00FF; border-radius: 6px; background: transparent;",
) -> None:
self._frame_parent = frame_parent
self._window_flags = window_flags
self._style_sheet = style_sheet
self._frame: QFrame | None = None
self._animation_group: QSequentialAnimationGroup | None = None
def highlight(self, widget: QWidget | None) -> None:
"""
Highlight the given widget with a pulsing frame.
"""
if widget is None or not shiboken6.isValid(widget):
return
frame = self._ensure_frame()
frame.hide()
geom = widget.frameGeometry()
top_left = widget.mapToGlobal(widget.rect().topLeft())
frame.setGeometry(top_left.x(), top_left.y(), geom.width(), geom.height())
frame.setWindowOpacity(1.0)
frame.show()
start_rect = QRect(
top_left.x() - 5, top_left.y() - 5, geom.width() + 10, geom.height() + 10
)
pulse = QPropertyAnimation(frame, b"geometry", frame)
pulse.setDuration(300)
pulse.setStartValue(start_rect)
pulse.setEndValue(QRect(top_left.x(), top_left.y(), geom.width(), geom.height()))
fade = QPropertyAnimation(frame, b"windowOpacity", frame)
fade.setDuration(2000)
fade.setStartValue(1.0)
fade.setEndValue(0.0)
fade.finished.connect(frame.hide)
if self._animation_group is not None:
old_group = self._animation_group
self._animation_group = None
old_group.stop()
old_group.deleteLater()
animation = QSequentialAnimationGroup(frame)
animation.addAnimation(pulse)
animation.addAnimation(fade)
animation.start()
self._animation_group = animation
def cleanup(self) -> None:
"""
Delete the highlight frame and cancel pending animations.
"""
if self._animation_group is not None:
self._animation_group.stop()
self._animation_group.deleteLater()
self._animation_group = None
if self._frame is not None:
self._frame.hide()
self._frame.deleteLater()
self._frame = None
@property
def frame(self) -> QFrame | None:
"""Return the currently allocated highlight frame (if any)."""
return self._frame
def _ensure_frame(self) -> QFrame:
if self._frame is None:
self._frame = QFrame(self._frame_parent, self._window_flags)
self._frame.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self._frame.setStyleSheet(self._style_sheet)
return self._frame

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Type, TypeVar, cast
import shiboken6 as shb
from bec_lib import bec_logger
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -31,6 +33,14 @@ logger = bec_logger.logger
TAncestor = TypeVar("TAncestor", bound=QWidget)
@dataclass(frozen=True)
class WidgetTreeNode:
widget: QWidget
parent: QWidget | None
depth: int
prefix: str
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@@ -320,6 +330,72 @@ class WidgetIO:
class WidgetHierarchy:
@staticmethod
def iter_widget_tree(widget: QWidget, *, exclude_internal_widgets: bool = True):
"""
Yield WidgetTreeNode entries for the widget hierarchy.
"""
visited: set[int] = set()
yield from WidgetHierarchy._iter_widget_tree_nodes(
widget, None, exclude_internal_widgets, visited, [], 0
)
@staticmethod
def _iter_widget_tree_nodes(
widget: QWidget,
parent: QWidget | None,
exclude_internal_widgets: bool,
visited: set[int],
branch_flags: list[bool],
depth: int,
):
if widget is None or not shb.isValid(widget):
return
widget_id = id(widget)
if widget_id in visited:
return
visited.add(widget_id)
prefix = WidgetHierarchy._build_prefix(branch_flags)
yield WidgetTreeNode(widget=widget, parent=parent, depth=depth, prefix=prefix)
children = WidgetHierarchy._filtered_children(widget, exclude_internal_widgets)
for idx, child in enumerate(children):
is_last = idx == len(children) - 1
yield from WidgetHierarchy._iter_widget_tree_nodes(
child,
widget,
exclude_internal_widgets,
visited,
branch_flags + [is_last],
depth + 1,
)
@staticmethod
def _build_prefix(branch_flags: list[bool]) -> str:
if not branch_flags:
return ""
parts: list[str] = []
for flag in branch_flags[:-1]:
parts.append(" " if flag else "")
parts.append("└─ " if branch_flags[-1] else "├─ ")
return "".join(parts)
@staticmethod
def _filtered_children(widget: QWidget, exclude_internal_widgets: bool) -> list[QWidget]:
children: list[QWidget] = []
for child in widget.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
if not shb.isValid(child):
continue
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
continue
children.append(child)
return children
@staticmethod
def print_widget_hierarchy(
widget,
@@ -345,52 +421,33 @@ class WidgetHierarchy:
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
is_bec = isinstance(widget, BECConnector)
if only_bec_widgets and not is_bec:
return
# 2) Determine and print the parent's info (closest BECConnector)
parent_info = ""
if show_parent and is_bec:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
if ancestor:
parent_label = ancestor.objectName() or ancestor.__class__.__name__
parent_info = f" parent={parent_label}"
else:
parent_info = " parent=None"
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
print(prefix + widget_info)
# 3) If it's a Waveform, explicitly print the curves
if isinstance(widget, Waveform):
for curve in widget.curves:
curve_prefix = prefix + " └─ "
print(
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
f"parent={widget.objectName()}"
)
# 4) Recursively handle each child if:
# - It's a QWidget
# - It is a BECConnector (or we don't care about filtering)
# - Its closest BECConnector parent is the current widget
for child in widget.findChildren(QWidget):
if only_bec_widgets and not isinstance(child, BECConnector):
for node in WidgetHierarchy.iter_widget_tree(
widget, exclude_internal_widgets=exclude_internal_widgets
):
current = node.widget
is_bec = isinstance(current, BECConnector)
if only_bec_widgets and not is_bec:
continue
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
child_prefix = prefix + " └─ "
WidgetHierarchy.print_widget_hierarchy(
child,
indent=indent + 1,
grab_values=grab_values,
prefix=child_prefix,
exclude_internal_widgets=exclude_internal_widgets,
only_bec_widgets=only_bec_widgets,
show_parent=show_parent,
)
parent_info = ""
if show_parent and is_bec:
ancestor = WidgetHierarchy.get_becwidget_ancestor(current)
if ancestor:
parent_label = ancestor.objectName() or ancestor.__class__.__name__
parent_info = f" parent={parent_label}"
else:
parent_info = " parent=None"
widget_info = f"{current.__class__.__name__} ({current.objectName()}){parent_info}"
print(node.prefix + widget_info)
if isinstance(current, Waveform):
for curve in current.curves:
curve_prefix = node.prefix + " "
print(
f"{curve_prefix}└─ {curve.__class__.__name__} ({curve.objectName()}) "
f"parent={current.objectName()}"
)
@staticmethod
def print_becconnector_hierarchy_from_app():
@@ -430,7 +487,7 @@ class WidgetHierarchy:
# 3) Build a map of (closest BECConnector parent) -> list of children
parent_map = defaultdict(list)
for w in bec_widgets:
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
parent_bec = WidgetHierarchy.get_becwidget_ancestor(w)
parent_map[parent_bec].append(w)
# 4) Define a recursive printer to show each object's children
@@ -467,10 +524,15 @@ class WidgetHierarchy:
print_tree(root, prefix=" ")
@staticmethod
def _get_becwidget_ancestor(widget):
def get_becwidget_ancestor(widget):
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
Args:
widget: Starting widget to find the ancestor for.
Returns:
The nearest ancestor that is a BECConnector, or None if not found.
"""
from bec_widgets.utils import BECConnector
@@ -580,7 +642,7 @@ class WidgetHierarchy:
if isinstance(widget, BECConnector):
connectors.append(widget)
for child in widget.findChildren(BECConnector):
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
if WidgetHierarchy.get_becwidget_ancestor(child) is widget:
connectors.append(child)
return connectors
@@ -611,7 +673,7 @@ class WidgetHierarchy:
is_bec_target = issubclass(ancestor_class, BECConnector)
if is_bec_target:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
ancestor = WidgetHierarchy.get_becwidget_ancestor(widget)
return cast(TAncestor, ancestor)
except Exception as e:
logger.error(f"Error importing BECConnector: {e}")

View File

@@ -7,7 +7,7 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.containers.qt_ads import CDockWidget
@@ -37,16 +37,16 @@ class AutoUpdates(BECMainWindow):
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = AdvancedDockArea(
self.dock_area = BECDockArea(
parent=self,
object_name="dock_area",
enable_profile_management=False,
restore_initial_profile=False,
startup_profile="skip",
)
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None
self._default_dock = None # type:ignore
self._default_dock = None # type: ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = True
@@ -63,7 +63,7 @@ class AutoUpdates(BECMainWindow):
Disconnect all connections for the auto updates.
"""
self.bec_dispatcher.disconnect_slot(
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
self._on_scan_status, MessageEndpoints.scan_status() # type: ignore
)
@property
@@ -244,10 +244,10 @@ class AutoUpdates(BECMainWindow):
wf = self.set_dock_to_widget("Waveform")
# Get the scan report devices reported by the scan
dev_x = info.scan_report_devices[0] # type:ignore
dev_x = info.scan_report_devices[0] # type: ignore
# For the y axis, get the selected device
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
if not dev_y:
return
@@ -256,8 +256,8 @@ class AutoUpdates(BECMainWindow):
# as the label and title
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
device_x=dev_x,
device_y=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
@@ -265,7 +265,7 @@ class AutoUpdates(BECMainWindow):
)
logger.info(
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
f"Auto Update [simple_line_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}"
)
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
@@ -279,8 +279,8 @@ class AutoUpdates(BECMainWindow):
scatter = self.set_dock_to_widget("ScatterWaveform")
# Get the scan report devices reported by the scan
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type: ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
if None in (dev_x, dev_y, dev_z):
return
@@ -288,11 +288,14 @@ class AutoUpdates(BECMainWindow):
# Clear the scatter waveform widget and plot the data
scatter.clear_all()
scatter.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
device_x=dev_x,
device_y=dev_y,
device_z=dev_z,
label=f"Scan {info.scan_number} - {dev_z}",
)
logger.info(
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
f"Auto Update [simple_grid_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}, device_z={dev_z}"
)
def best_effort(self, info: ScanStatusMessage) -> None:
@@ -306,8 +309,8 @@ class AutoUpdates(BECMainWindow):
# If the scan report devices are empty, there is nothing we can do
if not info.scan_report_devices:
return
dev_x = info.scan_report_devices[0] # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
dev_x = info.scan_report_devices[0] # type: ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
if not dev_y:
return
@@ -317,15 +320,17 @@ class AutoUpdates(BECMainWindow):
# Clear the waveform widget and plot the data
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
device_x=dev_x,
device_y=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
logger.info(
f"Auto Update [best_effort]: Started plot with: device_x={dev_x}, device_y={dev_y}"
)
#######################################################################
################# GUI Callbacks #######################################

View File

@@ -14,6 +14,7 @@ from shiboken6 import isValid
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.qt_ads import (
@@ -112,6 +113,7 @@ class DockAreaWidget(BECWidget, QWidget):
)
self._root_layout.addWidget(self.dock_manager, 1)
self._install_manager_parent_guards()
################################################################################
# Dock Utility Helpers
@@ -254,6 +256,54 @@ class DockAreaWidget(BECWidget, QWidget):
return lambda dock: self._default_close_handler(dock, widget)
def _install_manager_parent_guards(self) -> None:
"""
Track ADS structural changes so drag/drop-created tab areas keep stable parenting.
"""
self.dock_manager.dockAreaCreated.connect(self._normalize_all_dock_parents)
self.dock_manager.dockWidgetAdded.connect(self._normalize_all_dock_parents)
self.dock_manager.stateRestored.connect(self._normalize_all_dock_parents)
self.dock_manager.restoringState.connect(self._normalize_all_dock_parents)
self.dock_manager.focusedDockWidgetChanged.connect(self._normalize_all_dock_parents)
self._normalize_all_dock_parents()
def _iter_all_dock_areas(self) -> list[CDockAreaWidget]:
"""Return all dock areas from all known dock containers."""
areas: list[CDockAreaWidget] = []
for i in range(self.dock_manager.dockAreaCount()):
area = self.dock_manager.dockArea(i)
if area is None or not isValid(area):
continue
areas.append(area)
return areas
def _connect_dock_area_parent_guards(self) -> None:
"""Bind area-level tab/view events to parent normalization."""
for area in self._iter_all_dock_areas():
try:
area.currentChanged.connect(
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
)
area.viewToggled.connect(
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
)
except TypeError:
area.currentChanged.connect(self._normalize_all_dock_parents)
area.viewToggled.connect(self._normalize_all_dock_parents)
def _normalize_all_dock_parents(self, *_args) -> None:
"""
Ensure each dock has a stable parent after tab switches, re-docking, or restore.
"""
self._connect_dock_area_parent_guards()
for dock in self.dock_list():
if dock is None or not isValid(dock):
continue
area_widget = dock.dockAreaWidget()
target_parent = area_widget if area_widget is not None else self.dock_manager
if dock.parent() is not target_parent:
dock.setParent(target_parent)
def _make_dock(
self,
widget: QWidget,
@@ -356,6 +406,7 @@ class DockAreaWidget(BECWidget, QWidget):
self._apply_floating_state_to_dock(dock, floating_state)
if resolved_icon is not None:
dock.setIcon(resolved_icon)
self._normalize_all_dock_parents()
return dock
def _delete_dock(self, dock: CDockWidget) -> None:
@@ -1264,7 +1315,7 @@ class DockAreaWidget(BECWidget, QWidget):
or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses
such as `AdvancedDockArea` override the default to ``True``.
such as `BECDockArea` override the default to ``True``.
promote_central(bool): When True, promote the created dock to be the dock manager's
central widget (useful for editor stacks or other root content).
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
@@ -1334,37 +1385,40 @@ class DockAreaWidget(BECWidget, QWidget):
dock = self._create_dock_from_spec(spec)
return dock if return_dock else widget
def _iter_all_docks(self) -> list[CDockWidget]:
"""Return all docks, including those hosted in floating containers."""
docks = list(self.dock_manager.dockWidgets())
seen = {id(d) for d in docks}
for container in self.dock_manager.floatingWidgets():
if container is None:
continue
for dock in container.dockWidgets():
if dock is None:
continue
if id(dock) in seen:
continue
docks.append(dock)
seen.add(id(dock))
return docks
def dock_map(self) -> dict[str, CDockWidget]:
"""Return the dock widgets map as dictionary with names as keys."""
return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()}
return self.dock_manager.dockWidgetsMap()
def dock_list(self) -> list[CDockWidget]:
"""Return the list of dock widgets."""
return self._iter_all_docks()
return list(self.dock_map().values())
def widget_map(self) -> dict[str, QWidget]:
"""Return a dictionary mapping widget names to their corresponding widgets."""
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
def widget_map(self, bec_widgets_only: bool = True) -> dict[str, QWidget]:
"""
Return a dictionary mapping widget names to their corresponding widgets.
def widget_list(self) -> list[QWidget]:
"""Return a list of all widgets contained in the dock area."""
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
Args:
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
"""
widgets: dict[str, QWidget] = {}
for dock in self.dock_list():
widget = dock.widget()
if not isinstance(widget, QWidget):
continue
if bec_widgets_only and not isinstance(widget, BECConnector):
continue
widgets[dock.objectName()] = widget
return widgets
def widget_list(self, bec_widgets_only: bool = True) -> list[QWidget]:
"""
Return a list of widgets contained in the dock area.
Args:
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
"""
return list(self.widget_map(bec_widgets_only=bec_widgets_only).values())
@SafeSlot()
def attach_all(self):

View File

@@ -5,7 +5,7 @@ from typing import Literal, Mapping, Sequence
import slugify
from bec_lib import bec_logger
from qtpy.QtCore import QTimer, Signal
from qtpy.QtCore import Signal
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
@@ -19,6 +19,7 @@ from qtpy.QtWidgets import (
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.applications.views.view import ViewTourSteps
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
@@ -31,8 +32,8 @@ from bec_widgets.utils.toolbars.actions import (
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.profile_utils import (
SETTINGS_KEYS,
default_profile_candidates,
delete_profile_files,
@@ -55,14 +56,12 @@ from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
user_profile_candidates,
write_manifest,
)
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
RestoreProfileDialog,
SaveProfileDialog,
)
from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import (
WorkSpaceManager,
)
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actions import (
WorkspaceConnection,
workspace_bundle,
)
@@ -88,9 +87,10 @@ logger = bec_logger.logger
_PROFILE_NAMESPACE_UNSET = object()
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
StartupProfile = Literal["restore", "skip"] | str | None
class AdvancedDockArea(DockAreaWidget):
class BECDockArea(DockAreaWidget):
RPC = True
PLUGIN = False
USER_ACCESS = [
@@ -125,9 +125,7 @@ class AdvancedDockArea(DockAreaWidget):
instance_id: str | None = None,
auto_save_upon_exit: bool = True,
enable_profile_management: bool = True,
restore_initial_profile: bool = True,
init_profile: str | None = None,
start_empty: bool = False,
startup_profile: StartupProfile = "restore",
**kwargs,
):
self._profile_namespace_hint = profile_namespace
@@ -136,14 +134,9 @@ class AdvancedDockArea(DockAreaWidget):
self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None
self._auto_save_upon_exit = auto_save_upon_exit
self._profile_management_enabled = enable_profile_management
self._restore_initial_profile = restore_initial_profile
self._init_profile = init_profile
self._start_empty = start_empty
self._startup_profile = self._normalize_startup_profile(startup_profile)
super().__init__(
parent,
default_add_direction=default_add_direction,
title="Advanced Dock Area",
**kwargs,
parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
)
# Initialize mode property first (before toolbar setup)
@@ -163,14 +156,16 @@ class AdvancedDockArea(DockAreaWidget):
self._root_layout.insertWidget(0, self.toolbar)
# Populate and hook the workspace combo
self._refresh_workspace_list()
self._current_profile_name = None
self._empty_profile_active = False
self._empty_profile_consumed = False
self._pending_autosave_skip: tuple[str, str] | None = None
self._exit_snapshot_written = False
self._refresh_workspace_list()
# State manager
self.state_manager = WidgetStateManager(
self, serialize_from_root=True, root_id="AdvancedDockArea"
self, serialize_from_root=True, root_id="BECDockArea"
)
# Developer mode state
@@ -178,84 +173,85 @@ class AdvancedDockArea(DockAreaWidget):
# Initialize default editable state based on current lock
self._set_editable(True) # default to editable; will sync toolbar toggle below
if self._ensure_initial_profile():
self._refresh_workspace_list()
# Apply the requested mode after everything is set up
self.mode = mode
if self._restore_initial_profile:
self._fetch_initial_profile()
self._fetch_initial_profile()
def _ensure_initial_profile(self) -> bool:
@staticmethod
def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile:
"""
Ensure the "general" workspace profile always exists for the current namespace.
The "general" profile is mandatory and will be recreated if deleted.
If list_profile fails due to file permission or corrupted profiles, no action taken.
Returns:
bool: True if a profile was created, False otherwise.
Normalize startup profile values.
"""
namespace = self.profile_namespace
try:
existing_profiles = list_profiles(namespace)
except Exception as exc: # pragma: no cover - defensive guard
logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}")
return False
if startup_profile == "":
return None
return startup_profile
# Always ensure "general" profile exists
name = "general"
if name in existing_profiles:
return False
logger.info(
f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace."
)
self._write_profile_settings(name, namespace, save_preview=False)
set_quick_select(name, True, namespace=namespace)
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
return True
def _fetch_initial_profile(self):
# Restore last-used profile if available; otherwise fall back to combo selection
def _resolve_restore_startup_profile(self) -> str | None:
"""
Resolve the profile name when startup profile is set to "restore".
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
namespace = self.profile_namespace
init_profile = None
# First priority: use init_profile if explicitly provided
if self._init_profile:
init_profile = self._init_profile
else:
# Try to restore from last used profile
instance_id = self._last_profile_instance_id()
if instance_id:
inst_profile = get_last_profile(
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
)
if inst_profile and self._profile_exists(inst_profile, namespace):
init_profile = inst_profile
if not init_profile:
last = get_last_profile(namespace=namespace)
if last and self._profile_exists(last, namespace):
init_profile = last
else:
text = combo.currentText()
init_profile = text if text else None
if not init_profile:
# Fall back to "general" profile which is guaranteed to exist
if self._profile_exists("general", namespace):
init_profile = "general"
if init_profile:
self._load_initial_profile(init_profile)
instance_id = self._last_profile_instance_id()
if instance_id:
inst_profile = get_last_profile(
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
)
if inst_profile and self._profile_exists(inst_profile, namespace):
return inst_profile
last = get_last_profile(namespace=namespace)
if last and self._profile_exists(last, namespace):
return last
combo_text = combo.currentText().strip()
if combo_text and self._profile_exists(combo_text, namespace):
return combo_text
return None
def _fetch_initial_profile(self):
startup_profile = self._startup_profile
if startup_profile == "skip":
logger.debug("Skipping startup profile initialization.")
return
if startup_profile == "restore":
restored = self._resolve_restore_startup_profile()
if restored:
self._load_initial_profile(restored)
return
self._start_empty_workspace()
return
if startup_profile is None:
self._start_empty_workspace()
return
self._load_initial_profile(startup_profile)
def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile."""
self.load_profile(name, start_empty=self._start_empty)
self.load_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
combo.setCurrentText(name)
if not self._empty_profile_active:
combo.setCurrentText(name)
combo.blockSignals(False)
def _start_empty_workspace(self) -> None:
"""
Initialize the dock area in transient empty-profile mode.
"""
if (
getattr(self, "_current_profile_name", None) is None
and not self._empty_profile_consumed
):
self.delete_all()
self._enter_empty_profile_state()
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
prefs = getattr(dock, "_dock_preferences", {}) or {}
if prefs.get("show_settings_action") is None:
@@ -305,7 +301,7 @@ class AdvancedDockArea(DockAreaWidget):
or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses
such as `AdvancedDockArea` override the default to ``True``.
such as `BECDockArea` override the default to ``True``.
promote_central(bool): When True, promote the created dock to be the dock manager's
central widget (useful for editor stacks or other root content).
object_name(str | None): Optional object name to assign to the created widget.
@@ -602,13 +598,6 @@ class AdvancedDockArea(DockAreaWidget):
"""Namespace used to scope user/default profile files for this dock area."""
return self._resolve_profile_namespace()
def _active_profile_name_or_default(self) -> str:
name = getattr(self, "_current_profile_name", None)
if not name:
name = "general"
self._current_profile_name = name
return name
def _profile_exists(self, name: str, namespace: str | None) -> bool:
return any(
os.path.exists(path) for path in user_profile_candidates(name, namespace)
@@ -676,12 +665,26 @@ class AdvancedDockArea(DockAreaWidget):
name: The profile name.
namespace: The profile namespace.
"""
self._empty_profile_active = False
self._empty_profile_consumed = True
self._current_profile_name = name
self.profile_changed.emit(name)
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name)
def _enter_empty_profile_state(self) -> None:
"""
Switch to the transient empty workspace state.
In this mode there is no active profile name, the toolbar shows an
explicit blank profile entry, and no autosave on shutdown is performed.
"""
self._empty_profile_active = True
self._current_profile_name = None
self._pending_autosave_skip = None
self._refresh_workspace_list()
@SafeSlot()
def list_profiles(self) -> list[str]:
"""
@@ -815,10 +818,10 @@ class AdvancedDockArea(DockAreaWidget):
"""
self.save_profile(name, show_dialog=True)
@SafeSlot()
@SafeSlot(str)
@SafeSlot(str, bool)
@rpc_timeout(None)
def load_profile(self, name: str | None = None, start_empty: bool = False):
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
@@ -827,8 +830,10 @@ class AdvancedDockArea(DockAreaWidget):
Args:
name (str | None): The name of the profile to load. If None, prompts the user.
start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile.
"""
if name == "":
return
if not name: # Gui fallback if the name is not provided
name, ok = QInputDialog.getText(
self, "Load Workspace", "Enter the name of the workspace profile to load:"
@@ -860,10 +865,6 @@ class AdvancedDockArea(DockAreaWidget):
# Clear existing docks and remove all widgets
self.delete_all()
if start_empty:
self._finalize_profile_change(name, namespace)
return
# Rebuild widgets and restore states
for item in read_manifest(settings):
obj_name = item["object_name"]
@@ -1009,25 +1010,36 @@ class AdvancedDockArea(DockAreaWidget):
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
active_profile = getattr(self, "_current_profile_name", None)
empty_profile_active = bool(getattr(self, "_empty_profile_active", False))
namespace = self.profile_namespace
if hasattr(combo, "set_quick_profile_provider"):
combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns))
if hasattr(combo, "refresh_profiles"):
combo.refresh_profiles(active_profile)
if empty_profile_active:
combo.refresh_profiles(active_profile, show_empty_profile=True)
else:
combo.refresh_profiles(active_profile)
else:
# Fallback for regular QComboBox
combo.blockSignals(True)
combo.clear()
quick_profiles = list_quick_profiles(namespace=namespace)
items = list(quick_profiles)
items = [""] if empty_profile_active else []
items.extend(quick_profiles)
if active_profile and active_profile not in items:
items.insert(0, active_profile)
combo.addItems(items)
if active_profile:
if empty_profile_active:
idx = combo.findText("")
if idx >= 0:
combo.setCurrentIndex(idx)
elif active_profile:
idx = combo.findText(active_profile)
if idx >= 0:
combo.setCurrentIndex(idx)
if active_profile and active_profile not in quick_profiles:
if empty_profile_active:
combo.setToolTip("Unsaved empty workspace")
elif active_profile and active_profile not in quick_profiles:
combo.setToolTip("Active profile is not in quick select")
else:
combo.setToolTip("")
@@ -1132,7 +1144,16 @@ class AdvancedDockArea(DockAreaWidget):
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
return
name = self._active_profile_name_or_default()
if getattr(self, "_empty_profile_active", False):
logger.info("ADS prepare_for_shutdown: skipping autosave for unsaved empty workspace")
self._exit_snapshot_written = True
return
name = getattr(self, "_current_profile_name", None)
if not name:
logger.info("ADS prepare_for_shutdown: skipping autosave (no active profile)")
self._exit_snapshot_written = True
return
namespace = self.profile_namespace
settings = open_user_settings(name, namespace=namespace)
@@ -1140,6 +1161,33 @@ class AdvancedDockArea(DockAreaWidget):
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
self._exit_snapshot_written = True
def register_tour_steps(self, guided_tour, main_app):
"""Register Dock Area components with the guided tour.
Args:
guided_tour: The GuidedTour instance to register with.
main_app: The main application instance (for accessing set_current).
Returns:
ViewTourSteps | None: Model containing view title and step IDs.
"""
step_ids = []
# Register Dock Area toolbar
def get_dock_toolbar():
main_app.set_current("dock_area")
return (self.toolbar, None)
step_id = guided_tour.register_widget(
widget=get_dock_toolbar,
title="Dock Area Toolbar",
text="Use this toolbar to add widgets, manage workspaces, save and load profiles, and control the layout of your workspace.",
)
step_ids.append(step_id)
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
def cleanup(self):
"""
Cleanup the dock area.
@@ -1163,7 +1211,7 @@ if __name__ == "__main__": # pragma: no cover
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True)
ads = BECDockArea(mode="creator", enable_profile_management=True, root_widget=True)
window.setCentralWidget(ads)
window.show()

View File

@@ -1,5 +1,5 @@
"""
Utilities for managing AdvancedDockArea profiles stored in INI files.
Utilities for managing BECDockArea profiles stored in INI files.
Policy:
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
@@ -36,12 +36,12 @@ ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
def module_profiles_dir() -> str:
"""
Return the built-in AdvancedDockArea profiles directory bundled with the module.
Return the built-in BECDockArea profiles directory bundled with the module.
Returns:
str: Absolute path of the read-only module profiles directory.
"""
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
return os.path.join(MODULE_PATH, "containers", "dock_area", "profiles")
@lru_cache(maxsize=1)
@@ -115,12 +115,12 @@ def _settings_profiles_root() -> str:
str: Absolute path to the profiles root. The directory is created if missing.
"""
client = BECClient()
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
bec_widgets_settings = client._service_config.config.get("widgets_settings")
bec_widgets_setting_path = (
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
)
default_path = os.path.join(bec_widgets_setting_path, "profiles")
root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
root = os.path.expanduser(os.environ.get("BECWIDGETS_PROFILE_DIR", default_path))
os.makedirs(root, exist_ok=True)
return root
@@ -138,7 +138,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
"""
base = os.path.join(_settings_profiles_root(), segment)
ns = slugify.slugify(namespace, separator="_") if namespace else None
path = os.path.join(base, ns) if ns else base
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
os.makedirs(path, exist_ok=True)
return path

View File

@@ -28,7 +28,7 @@ from qtpy.QtWidgets import (
from bec_widgets import BECWidget, SafeSlot
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
from bec_widgets.widgets.containers.dock_area.profile_utils import (
get_profile_info,
is_quick_select,
list_profiles,
@@ -330,7 +330,7 @@ class WorkSpaceManager(BECWidget, QWidget):
return
self.target_widget.save_profile_dialog()
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
# BECDockArea will emit profile_changed which will trigger table refresh,
# but ensure the UI stays in sync even if the signal is delayed.
self.render_table()
current = getattr(self.target_widget, "_current_profile_name", None)

View File

@@ -10,7 +10,7 @@ from bec_widgets import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles
from bec_widgets.widgets.containers.dock_area.profile_utils import list_quick_profiles
class ProfileComboBox(QComboBox):
@@ -24,12 +24,15 @@ class ProfileComboBox(QComboBox):
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider
def refresh_profiles(self, active_profile: str | None = None):
def refresh_profiles(
self, active_profile: str | None = None, show_empty_profile: bool = False
) -> None:
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
"""
current_text = active_profile or self.currentText()
@@ -39,9 +42,22 @@ class ProfileComboBox(QComboBox):
quick_profiles = self._quick_provider()
quick_set = set(quick_profiles)
items = list(quick_profiles)
items: list[str] = []
if show_empty_profile:
items.append("")
if active_profile and active_profile not in quick_set:
items.insert(0, active_profile)
items.append(active_profile)
for profile in quick_profiles:
if profile not in items:
items.append(profile)
if active_profile and active_profile not in quick_set:
# keep active profile at the top when not in quick list
items.remove(active_profile)
insert_pos = 1 if show_empty_profile else 0
items.insert(insert_pos, active_profile)
for profile in items:
self.addItem(profile)
@@ -52,6 +68,15 @@ class ProfileComboBox(QComboBox):
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
if profile == "":
self.setItemData(idx, "Unsaved empty workspace", Qt.ItemDataRole.ToolTipRole)
if active_profile is None:
font = QFont(self.font())
font.setItalic(True)
self.setItemData(idx, font, Qt.ItemDataRole.FontRole)
self.setCurrentIndex(idx)
continue
if active_profile and profile == active_profile:
tooltip = "Active workspace profile"
if profile not in quick_set:
@@ -69,16 +94,23 @@ class ProfileComboBox(QComboBox):
self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole)
# Restore selection if possible
index = self.findText(current_text)
if index >= 0:
self.setCurrentIndex(index)
if show_empty_profile and active_profile is None:
empty_idx = self.findText("")
if empty_idx >= 0:
self.setCurrentIndex(empty_idx)
else:
index = self.findText(current_text)
if index >= 0:
self.setCurrentIndex(index)
self.blockSignals(False)
if active_profile and self.currentText() != active_profile:
idx = self.findText(active_profile)
if idx >= 0:
self.setCurrentIndex(idx)
if active_profile and active_profile not in quick_set:
if show_empty_profile and self.currentText() == "":
self.setToolTip("Unsaved empty workspace")
elif active_profile and active_profile not in quick_set:
self.setToolTip("Active profile is not in quick select")
else:
self.setToolTip("")
@@ -86,7 +118,7 @@ class ProfileComboBox(QComboBox):
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for AdvancedDockArea.
Creates a workspace toolbar bundle for BECDockArea.
Args:
components (ToolbarComponents): The components to be added to the bundle.
@@ -139,7 +171,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
class WorkspaceConnection(BundleConnection):
"""
Connection class for workspace actions in AdvancedDockArea.
Connection class for workspace actions in BECDockArea.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):

View File

@@ -101,14 +101,12 @@ class Explorer(BECWidget, QWidget):
palette = get_theme_palette()
separator_color = palette.mid().color()
self.splitter.setStyleSheet(
f"""
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"""

View File

@@ -134,15 +134,13 @@ class NotificationToast(QFrame):
bg.setAlphaF(0.30)
icon_bg = bg.name(QtGui.QColor.HexArgb)
icon_btn.setFixedSize(40, 40)
icon_btn.setStyleSheet(
f"""
icon_btn.setStyleSheet(f"""
QToolButton {{
background: {icon_bg};
border: none;
border-radius: 20px; /* perfect circle */
}}
"""
)
""")
title_lbl = QtWidgets.QLabel(self._title)
@@ -327,15 +325,13 @@ class NotificationToast(QFrame):
bg = QtGui.QColor(SEVERITY[value.value]["color"])
bg.setAlphaF(0.30)
icon_bg = bg.name(QtGui.QColor.HexArgb)
self._icon_btn.setStyleSheet(
f"""
self._icon_btn.setStyleSheet(f"""
QToolButton {{
background: {icon_bg};
border: none;
border-radius: 20px;
}}
"""
)
""")
self.apply_theme(self._theme)
# keep injected gradient in sync
if getattr(self, "_hg_enabled", False):
@@ -391,8 +387,7 @@ class NotificationToast(QFrame):
card_bg.setAlphaF(0.88)
btn_hover = self._accent_color.name()
self.setStyleSheet(
f"""
self.setStyleSheet(f"""
#NotificationToast {{
background: {card_bg.name(QtGui.QColor.HexArgb)};
border-radius: 12px;
@@ -406,18 +401,15 @@ class NotificationToast(QFrame):
font-size: 14px;
}}
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
"""
)
""")
# traceback panel colours
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
self.trace_view.setStyleSheet(
f"""
self.trace_view.setStyleSheet(f"""
background:{trace_bg};
color:{palette['body']};
border:none;
border-radius:8px;
"""
)
""")
# icon glyph vs badge background: darker badge, lighter icon in light mode
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
@@ -438,15 +430,13 @@ class NotificationToast(QFrame):
else:
badge_bg.setAlphaF(0.30)
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
self._icon_btn.setStyleSheet(
f"""
self._icon_btn.setStyleSheet(f"""
QToolButton {{
background: {icon_bg};
border: none;
border-radius: 20px;
}}
"""
)
""")
# stronger accent wash in light mode, slightly stronger in dark too
self._accent_alpha = 110 if theme == "light" else 60
@@ -593,8 +583,7 @@ class NotificationCentre(QScrollArea):
self.setWidgetResizable(True)
# transparent background so only the toast cards are visible
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
self.setStyleSheet(
"""
self.setStyleSheet("""
#NotificationCentre { background: transparent; }
#NotificationCentre QScrollBar:vertical {
background: transparent;
@@ -610,8 +599,7 @@ class NotificationCentre(QScrollArea):
#NotificationCentre QScrollBar::sub-line:vertical { height: 0; }
#NotificationCentre QScrollBar::add-page:vertical,
#NotificationCentre QScrollBar::sub-page:vertical { background: transparent; }
"""
)
""")
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setFrameShape(QtWidgets.QFrame.NoFrame)
self.setFixedWidth(fixed_width)
@@ -958,8 +946,7 @@ class NotificationIndicator(QWidget):
self._group.buttonToggled.connect(self._button_toggled)
# minimalistic look: no frames or backgrounds on the buttons
self.setStyleSheet(
"""
self.setStyleSheet("""
QToolButton {
border: none;
background: transparent;
@@ -970,8 +957,7 @@ class NotificationIndicator(QWidget):
background: rgba(255, 255, 255, 40);
font-weight: 600;
}
"""
)
""")
# initial state: none checked (autodismiss behaviour)
for k in kinds:

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
@@ -31,12 +31,17 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
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.progress.scan_progressbar.scan_progressbar import ScanProgressBar
from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import (
WidgetHierarchyDialog,
)
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.ApplicationAttribute.AA_DontUseNativeMenuBar, True)
logger = bec_logger.logger
class BECMainWindow(BECWidget, QMainWindow):
RPC = True
@@ -49,6 +54,7 @@ class BECMainWindow(BECWidget, QMainWindow):
self.app = QApplication.instance()
self.status_bar = self.statusBar()
self._launcher_window = None
self.setWindowTitle(window_title)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
@@ -57,6 +63,7 @@ class BECMainWindow(BECWidget, QMainWindow):
self.notification_broker = BECNotificationBroker(parent=self)
self._nc_margin = 16
self._position_notification_centre()
self._widget_hierarchy_dialog: WidgetHierarchyDialog | None = None
# Init ui
self._init_ui()
@@ -189,14 +196,18 @@ class BECMainWindow(BECWidget, QMainWindow):
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple = ScanProgressBar(
self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False
)
self._scan_progress_bar_simple.show_elapsed_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.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
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, rpc_exposed=False, rpc_passthrough_children=False
)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
@@ -254,7 +265,7 @@ class BECMainWindow(BECWidget, QMainWindow):
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def _fetch_theme(self) -> str:
def fetch_theme(self) -> str:
return self.app.theme.theme
def _get_launcher_from_qapp(self):
@@ -275,6 +286,16 @@ class BECMainWindow(BECWidget, QMainWindow):
Show the launcher if it exists.
"""
launcher = self._get_launcher_from_qapp()
if launcher is None:
from bec_widgets.applications.launch_window import LaunchWindow
cli_server = getattr(self.bec_dispatcher, "cli_server", None)
if cli_server is None:
logger.warning("Cannot open launcher: CLI server is not available.")
return
launcher = LaunchWindow(gui_id=f"{cli_server.gui_id}:launcher")
launcher.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore[arg-type]
self._launcher_window = launcher
if launcher:
launcher.show()
launcher.activateWindow()
@@ -312,6 +333,11 @@ class BECMainWindow(BECWidget, QMainWindow):
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
theme_menu.addSeparator()
widget_tree_action = QAction("Show Widget Hierarchy", self)
widget_tree_action.triggered.connect(self._show_widget_hierarchy_dialog)
theme_menu.addAction(widget_tree_action)
# Set the default theme
if hasattr(self.app, "theme") and self.app.theme:
theme_name = self.app.theme.theme.lower()
@@ -395,7 +421,23 @@ class BECMainWindow(BECWidget, QMainWindow):
return True
return super().event(event)
def _show_widget_hierarchy_dialog(self):
if self._widget_hierarchy_dialog is None:
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
dialog.destroyed.connect(lambda: setattr(self, "_widget_hierarchy_dialog", None))
self._widget_hierarchy_dialog = dialog
self._widget_hierarchy_dialog.refresh()
self._widget_hierarchy_dialog.show()
self._widget_hierarchy_dialog.raise_()
self._widget_hierarchy_dialog.activateWindow()
def cleanup(self):
# Widget hierarchy dialog cleanup
if self._widget_hierarchy_dialog is not None:
self._widget_hierarchy_dialog.close()
self._widget_hierarchy_dialog = None
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()

View File

@@ -123,7 +123,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
queue="emergency",
metadata={"RID": request_id, "response": False},
)
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
self.client.connector.send(MessageEndpoints.scan_queue_request(self.client.username), msg)
# pylint: disable=unused-argument
def _on_device_readback(

View File

@@ -266,6 +266,7 @@ class DeviceSignalInputBase(BECWidget):
Args:
device(str): Device to validate.
raise_on_false(bool): Raise ValueError if device is not found.
"""
if device in self.dev:
return True

View File

@@ -1,7 +1,7 @@
import json
from typing import Any, Callable, Generator, Iterable, TypeVar
from bec_lib.utils.json import ExtendedEncoder
from bec_lib.utils.json_extended import ExtendedEncoder
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
from qtpy.QtWidgets import QListWidgetItem

View File

@@ -51,8 +51,7 @@ class _DeviceEntryWidget(QFrame):
self.setToolTip(self._rich_text())
def _rich_text(self):
return dedent(
f"""
return dedent(f"""
<b><u><h2> {self._device_spec.name}: </h2></u></b>
<table>
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
@@ -60,8 +59,7 @@ class _DeviceEntryWidget(QFrame):
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
</table>
"""
)
""")
def setup_title_layout(self, device_spec: HashableDevice):
self._title_layout = QHBoxLayout()

View File

@@ -69,11 +69,12 @@ class DeviceTest(QtCore.QRunnable):
enable_connect: bool,
force_connect: bool,
timeout: float,
device_manager_ds: object | None = None,
):
super().__init__()
self.uuid = device_model.uuid
test_config = {device_model.device_name: device_model.device_config}
self.tester = StaticDeviceTest(config_dict=test_config)
self.tester = StaticDeviceTest(config_dict=test_config, device_manager_ds=device_manager_ds)
self.signals = DeviceTestResult()
self.device_config = device_model.device_config
self.enable_connect = enable_connect
@@ -752,11 +753,15 @@ class OphydValidation(BECWidget, QtWidgets.QWidget):
# Remove widget from list as it's safe to assume it can be loaded.
self._remove_device_config(widget.device_model.device_config)
return
dm_ds = None
if self.client:
dm_ds = getattr(self.client, "device_manager", None)
runnable = DeviceTest(
device_model=widget.device_model,
enable_connect=connect,
force_connect=force_connect,
timeout=timeout,
device_manager_ds=dm_ds,
)
widget.validation_scheduled()
if self.thread_pool_manager:

View File

@@ -9,6 +9,7 @@ from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
@@ -171,7 +172,13 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self.layout.addWidget(self._metadata_form)
# Wrap metadata form in a group box
self._metadata_group = QGroupBox("Scan Metadata", self)
self._metadata_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
metadata_layout = QVBoxLayout(self._metadata_group)
metadata_layout.addWidget(self._metadata_form)
self.layout.addWidget(self._metadata_group)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)

View File

@@ -275,12 +275,10 @@ class LMFitDialog(BECWidget, QWidget):
button.setEnabled(True)
else:
button.setEnabled(False)
button.setStyleSheet(
f"""
button.setStyleSheet(f"""
QPushButton:enabled {{ background-color: {self._accent_colors.success.name()};color: white; }}
QPushButton:disabled {{ background-color: grey;color: white; }}
"""
)
""")
self.action_buttons[param_name] = button
layout = QVBoxLayout()
layout.addWidget(self.action_buttons[param_name])

View File

@@ -47,15 +47,13 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
)
def _init_bec_kernel(self):
self.execute(
"""
self.execute("""
from bec_ipython_client.main import BECIPythonClient
bec = BECIPythonClient()
bec.start()
dev = bec.device_manager.devices if bec else None
scans = bec.scans if bec else None
"""
)
""")
def _cleanup_bec(self):
if getattr(self, "ipyclient", None) is not None and self.inprocess is True:

View File

@@ -6,10 +6,10 @@ from typing import Any, cast
from bec_lib.logger import bec_logger
from bec_lib.macro_update_handler import has_executable_code
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
@@ -36,12 +36,12 @@ class MonacoDock(DockAreaWidget):
**kwargs,
)
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
self.dock_manager.installEventFilter(self)
self._last_focused_editor: CDockWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed)
initial_editor = self.add_editor()
if isinstance(initial_editor, CDockWidget):
self.last_focused_editor = initial_editor
self._install_manager_scan_and_fix_guards()
def _create_editor_widget(self) -> MonacoWidget:
"""Create a configured Monaco editor widget."""
@@ -73,7 +73,8 @@ class MonacoDock(DockAreaWidget):
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
self.save_enabled.emit(widget.modified)
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
@staticmethod
def _update_tab_title_for_modification(dock: CDockWidget, modified: bool):
"""Update the tab title to show modification status with a dot indicator."""
current_title = dock.windowTitle()
@@ -98,14 +99,12 @@ class MonacoDock(DockAreaWidget):
return
active_sig = signatures[signature.get("activeSignature", 0)]
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
# Get signature label and documentation
label = active_sig.get("label", "")
doc_obj = active_sig.get("documentation", {})
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
# Format the markdown output
# Format the Markdown output
markdown = f"```python\n{label}\n```\n\n{documentation}"
self.signature_help.emit(markdown)
@@ -156,9 +155,10 @@ class MonacoDock(DockAreaWidget):
if self.last_focused_editor is dock:
self.last_focused_editor = None
# After topology changes, make sure single-tab areas get a plus button
QTimer.singleShot(0, self._scan_and_fix_areas)
self._scan_and_fix_areas()
def reset_widget(self, widget: MonacoWidget):
@staticmethod
def reset_widget(widget: MonacoWidget):
"""
Reset the given Monaco editor widget to its initial state.
@@ -193,23 +193,23 @@ class MonacoDock(DockAreaWidget):
# pylint: disable=protected-access
area._monaco_plus_btn = plus_btn
def _scan_and_fix_areas(self):
def _install_manager_scan_and_fix_guards(self) -> None:
"""
Track ADS structural changes to trigger scan and fix of dock areas for plus button injection.
"""
self.dock_manager.dockAreaCreated.connect(self._scan_and_fix_areas)
self.dock_manager.dockWidgetAdded.connect(self._scan_and_fix_areas)
self.dock_manager.stateRestored.connect(self._scan_and_fix_areas)
self.dock_manager.restoringState.connect(self._scan_and_fix_areas)
self.dock_manager.focusedDockWidgetChanged.connect(self._scan_and_fix_areas)
self._scan_and_fix_areas()
def _scan_and_fix_areas(self, *_arg):
# Find all dock areas under this manager and ensure each single-tab area has a plus button
areas = self.dock_manager.findChildren(CDockAreaWidget)
for a in areas:
self._ensure_area_plus(a)
def eventFilter(self, obj, event):
# Track dock manager events
if obj is self.dock_manager and event.type() in (
QEvent.Type.ChildAdded,
QEvent.Type.ChildRemoved,
QEvent.Type.LayoutRequest,
):
QTimer.singleShot(0, self._scan_and_fix_areas)
return super().eventFilter(obj, event)
def add_editor(
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
) -> CDockWidget:
@@ -258,7 +258,7 @@ class MonacoDock(DockAreaWidget):
if area_widget is not None:
self._ensure_area_plus(area_widget)
QTimer.singleShot(0, self._scan_and_fix_areas)
self._scan_and_fix_areas()
self.last_focused_editor = dock
return dock

View File

@@ -362,8 +362,7 @@ if __name__ == "__main__": # pragma: no cover
widget.set_language("python")
widget.set_theme("vs-dark")
widget.editor.set_minimap_enabled(False)
widget.set_text(
"""
widget.set_text("""
import numpy as np
from typing import TYPE_CHECKING
@@ -380,8 +379,7 @@ if TYPE_CHECKING:
# This is a comment
def hello_world():
print("Hello, world!")
"""
)
""")
widget.set_highlighted_lines(1, 3)
widget.show()
qapp.exec_()

View File

@@ -53,6 +53,8 @@ class ScanMetadata(PydanticModelForm):
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
self._layout.setContentsMargins(0, 0, 0, 0)
self._form_grid_container.layout().setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -78,12 +80,27 @@ class ScanMetadata(PydanticModelForm):
def get_form_data(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
form_data = self._additional_metadata.dump_dict() | self._dict_from_grid()
# If scan_name is empty, set it to the current scan
if "scan_name" in form_data and not form_data["scan_name"]:
form_data["scan_name"] = self._scan_name
return form_data
def populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
super().populate()
# Set scan_name field to current scan if it exists and is empty
if "scan_name" not in self.widget_dict:
return
scan_name_widget = self.widget_dict["scan_name"]
if not hasattr(scan_name_widget, "getValue") or scan_name_widget.getValue():
return
if hasattr(scan_name_widget, "setValue"):
scan_name_widget.setValue(self._scan_name)
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))

View File

@@ -1,15 +0,0 @@
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.vscode.vs_code_editor_plugin import VSCodeEditorPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(VSCodeEditorPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

View File

@@ -1,57 +0,0 @@
# 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.vscode.vscode import VSCodeEditor
DOM_XML = """
<ui language='c++'>
<widget class='VSCodeEditor' name='vs_code_editor'>
</widget>
</ui>
"""
class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = VSCodeEditor(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Developer"
def icon(self):
return designer_material_icon(VSCodeEditor.ICON_NAME)
def includeFile(self):
return "vs_code_editor"
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 "VSCodeEditor"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -1,203 +0,0 @@
import os
import select
import shlex
import signal
import socket
import subprocess
from typing import Literal
from pydantic import BaseModel
from qtpy.QtCore import Signal, Slot
from bec_widgets.widgets.editors.website.website import WebsiteWidget
class VSCodeInstructionMessage(BaseModel):
command: Literal["open", "write", "close", "zenMode", "save", "new", "setCursor"]
content: str = ""
def get_free_port():
"""
Get a free port on the local machine.
Returns:
int: The free port number
"""
sock = socket.socket()
sock.bind(("", 0))
port = sock.getsockname()[1]
sock.close()
return port
class VSCodeEditor(WebsiteWidget):
"""
A widget to display the VSCode editor.
"""
file_saved = Signal(str)
token = "bec"
host = "127.0.0.1"
PLUGIN = True
USER_ACCESS = []
ICON_NAME = "developer_mode_tv"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
self.process = None
self.port = get_free_port()
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
self.start_server()
self.bec_dispatcher.connect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
def start_server(self):
"""
Start the server.
This method starts the server for the VSCode editor in a subprocess.
"""
env = os.environ.copy()
env["BEC_Widgets_GUIID"] = self.gui_id
env["BEC_REDIS_HOST"] = self.client.connector.host
cmd = shlex.split(
f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms"
)
self.process = subprocess.Popen(
cmd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
env=env,
)
os.set_blocking(self.process.stdout.fileno(), False)
while self.process.poll() is None:
readylist, _, _ = select.select([self.process.stdout], [], [], 1)
if self.process.stdout in readylist:
output = self.process.stdout.read(1024)
if output and f"available at {self._url}" in output:
break
self.set_url(self._url)
self.wait_until_loaded()
@Slot(str)
def open_file(self, file_path: str):
"""
Open a file in the VSCode editor.
Args:
file_path: The file path to open
"""
msg = VSCodeInstructionMessage(command="open", content=f"file://{file_path}")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot(dict, dict)
def on_vscode_event(self, content, _metadata):
"""
Handle the VSCode event. VSCode events are received as RawMessages.
Args:
content: The content of the event
metadata: The metadata of the event
"""
# the message also contains the content but I think is fine for now to just emit the file path
if not isinstance(content["data"], dict):
return
if "uri" not in content["data"]:
return
if not content["data"]["uri"].startswith("file://"):
return
file_path = content["data"]["uri"].split("file://")[1]
self.file_saved.emit(file_path)
@Slot()
def save_file(self):
"""
Save the file in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="save")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot()
def new_file(self):
"""
Create a new file in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="new")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot()
def close_file(self):
"""
Close the file in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="close")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot(str)
def write_file(self, content: str):
"""
Write content to the file in the VSCode editor.
Args:
content: The content to write
"""
msg = VSCodeInstructionMessage(command="write", content=content)
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot()
def zen_mode(self):
"""
Toggle the Zen mode in the VSCode editor.
"""
msg = VSCodeInstructionMessage(command="zenMode")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
@Slot(int, int)
def set_cursor(self, line: int, column: int):
"""
Set the cursor in the VSCode editor.
Args:
line: The line number
column: The column number
"""
msg = VSCodeInstructionMessage(command="setCursor", content=f"{line},{column}")
self.client.connector.raw_send(f"vscode-instructions/{self.gui_id}", msg.model_dump_json())
def cleanup_vscode(self):
"""
Cleanup the VSCode editor.
"""
if not self.process or self.process.poll() is not None:
return
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait()
def cleanup(self):
"""
Cleanup the widget. This method is called from the dock area when the widget is removed.
"""
self.bec_dispatcher.disconnect_slot(self.on_vscode_event, f"vscode-events/{self.gui_id}")
self.cleanup_vscode()
return super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = VSCodeEditor(gui_id="unknown")
widget.show()
app.exec_()
widget.bec_dispatcher.disconnect_all()
widget.client.shutdown()

View File

@@ -35,8 +35,8 @@ logger = bec_logger.logger
class HeatmapDeviceSignal(BaseModel):
"""The configuration of a signal in the scatter waveform widget."""
name: str
entry: str
device: str
signal: str
model_config: dict = {"validate_assignment": True}
@@ -65,13 +65,13 @@ class HeatmapConfig(ConnectionConfig):
lock_aspect_ratio: bool = Field(
False, description="Whether to lock the aspect ratio of the image."
)
x_device: HeatmapDeviceSignal | None = Field(
device_x: HeatmapDeviceSignal | None = Field(
None, description="The x device signal of the heatmap."
)
y_device: HeatmapDeviceSignal | None = Field(
device_y: HeatmapDeviceSignal | None = Field(
None, description="The y device signal of the heatmap."
)
z_device: HeatmapDeviceSignal | None = Field(
device_z: HeatmapDeviceSignal | None = Field(
None, description="The z device signal of the heatmap."
)
@@ -204,18 +204,18 @@ class Heatmap(ImageBase):
"rois",
"plot",
# Device properties
"x_device_name",
"x_device_name.setter",
"x_device_entry",
"x_device_entry.setter",
"y_device_name",
"y_device_name.setter",
"y_device_entry",
"y_device_entry.setter",
"z_device_name",
"z_device_name.setter",
"z_device_entry",
"z_device_entry.setter",
"device_x",
"device_x.setter",
"signal_x",
"signal_x.setter",
"device_y",
"device_y.setter",
"signal_y",
"signal_y.setter",
"device_z",
"device_z.setter",
"signal_z",
"signal_z.setter",
]
PLUGIN = True
@@ -238,9 +238,9 @@ class Heatmap(ImageBase):
interpolation="linear",
oversampling_factor=1.0,
lock_aspect_ratio=False,
x_device=None,
y_device=None,
z_device=None,
device_x=None,
device_y=None,
device_z=None,
)
super().__init__(parent=parent, config=config, theme_update=True, **kwargs)
self._image_config = config
@@ -314,12 +314,12 @@ class Heatmap(ImageBase):
@SafeSlot(popup_error=True)
def plot(
self,
x_name: str,
y_name: str,
z_name: str,
x_entry: None | str = None,
y_entry: None | str = None,
z_entry: None | str = None,
device_x: str,
device_y: str,
device_z: str,
signal_x: None | str = None,
signal_y: None | str = None,
signal_z: None | str = None,
color_map: str | None = "plasma",
validate_bec: bool = True,
interpolation: Literal["linear", "nearest"] | None = None,
@@ -333,12 +333,12 @@ class Heatmap(ImageBase):
Plot the heatmap with the given x, y, and z data.
Args:
x_name (str): The name of the x-axis signal.
y_name (str): The name of the y-axis signal.
z_name (str): The name of the z-axis signal.
x_entry (str | None): The entry for the x-axis signal.
y_entry (str | None): The entry for the y-axis signal.
z_entry (str | None): The entry for the z-axis signal.
device_x (str): The name of the x-axis device signal.
device_y (str): The name of the y-axis device signal.
device_z (str): The name of the z-axis device signal.
signal_x (str | None): The entry for the x-axis device signal.
signal_y (str | None): The entry for the y-axis device signal.
signal_z (str | None): The entry for the z-axis device signal.
color_map (str | None): The color map to use for the heatmap.
validate_bec (bool): Whether to validate the entries against BEC signals.
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
@@ -349,13 +349,13 @@ class Heatmap(ImageBase):
reload (bool): Whether to reload the heatmap with new data.
"""
if validate_bec:
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
signal_x = self.entry_validator.validate_signal(device_x, signal_x)
signal_y = self.entry_validator.validate_signal(device_y, signal_y)
signal_z = self.entry_validator.validate_signal(device_z, signal_z)
if x_entry is None or y_entry is None or z_entry is None:
if signal_x is None or signal_y is None or signal_z is None:
raise ValueError("x, y, and z entries must be provided.")
if x_name is None or y_name is None or z_name is None:
if device_x is None or device_y is None or device_z is None:
raise ValueError("x, y, and z names must be provided.")
if interpolation is None:
@@ -374,24 +374,24 @@ class Heatmap(ImageBase):
show_config_label = self._image_config.show_config_label
def _device_key(device: HeatmapDeviceSignal | None) -> tuple[str | None, str | None]:
return (device.name if device else None, device.entry if device else None)
return (device.device if device else None, device.signal if device else None)
prev_cfg = getattr(self, "_image_config", None)
config_changed = False
if prev_cfg and prev_cfg.x_device and prev_cfg.y_device and prev_cfg.z_device:
if prev_cfg and prev_cfg.device_x and prev_cfg.device_y and prev_cfg.device_z:
config_changed = any(
(
_device_key(prev_cfg.x_device) != (x_name, x_entry),
_device_key(prev_cfg.y_device) != (y_name, y_entry),
_device_key(prev_cfg.z_device) != (z_name, z_entry),
_device_key(prev_cfg.device_x) != (device_x, signal_x),
_device_key(prev_cfg.device_y) != (device_y, signal_y),
_device_key(prev_cfg.device_z) != (device_z, signal_z),
)
)
self._image_config = HeatmapConfig(
parent_id=self.gui_id,
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
y_device=HeatmapDeviceSignal(name=y_name, entry=y_entry),
z_device=HeatmapDeviceSignal(name=z_name, entry=z_entry),
device_x=HeatmapDeviceSignal(device=device_x, signal=signal_x),
device_y=HeatmapDeviceSignal(device=device_y, signal=signal_y),
device_z=HeatmapDeviceSignal(device=device_z, signal=signal_z),
color_map=color_map,
color_bar=None,
interpolation=interpolation,
@@ -428,26 +428,26 @@ class Heatmap(ImageBase):
return
# Safely get device names (might be None if not yet configured)
x_device = self._image_config.x_device
y_device = self._image_config.y_device
z_device = self._image_config.z_device
device_x = self._image_config.device_x
device_y = self._image_config.device_y
device_z = self._image_config.device_z
x_name = x_device.name if x_device else None
y_name = y_device.name if y_device else None
z_name = z_device.name if z_device else None
device_x_name = device_x.device if device_x else None
device_y_name = device_y.device if device_y else None
device_z_name = device_z.device if device_z else None
if x_name is not None:
self.x_label = x_name # type: ignore
x_dev = self.dev.get(x_name)
if device_x_name is not None:
self.x_label = device_x_name # type: ignore
x_dev = self.dev.get(device_x_name)
if x_dev and hasattr(x_dev, "egu"):
self.x_label_units = x_dev.egu()
if y_name is not None:
self.y_label = y_name # type: ignore
y_dev = self.dev.get(y_name)
if device_y_name is not None:
self.y_label = device_y_name # type: ignore
y_dev = self.dev.get(device_y_name)
if y_dev and hasattr(y_dev, "egu"):
self.y_label_units = y_dev.egu()
if z_name is not None:
self.title = z_name
if device_z_name is not None:
self.title = device_z_name
def _init_toolbar_heatmap(self):
"""
@@ -572,23 +572,23 @@ class Heatmap(ImageBase):
if self._image_config is None:
return
try:
x_name = self._image_config.x_device.name
x_entry = self._image_config.x_device.entry
y_name = self._image_config.y_device.name
y_entry = self._image_config.y_device.entry
z_name = self._image_config.z_device.name
z_entry = self._image_config.z_device.entry
device_x = self._image_config.device_x.device
signal_x = self._image_config.device_x.signal
device_y = self._image_config.device_y.device
signal_y = self._image_config.device_y.signal
device_z = self._image_config.device_z.device
signal_z = self._image_config.device_z.signal
except AttributeError:
return
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
x_data = data.get(device_x, {}).get(signal_x, {}).get(access_key, None)
y_data = data.get(device_y, {}).get(signal_y, {}).get(access_key, None)
z_data = data.get(device_z, {}).get(signal_z, {}).get(access_key, None)
else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
x_data = data.get(device_x, {}).get(signal_x, {}).read().get("value", None)
y_data = data.get(device_y, {}).get(signal_y, {}).read().get("value", None)
z_data = data.get(device_z, {}).get(signal_z, {}).read().get("value", None)
if not isinstance(x_data, list):
x_data = x_data.tolist() if isinstance(x_data, np.ndarray) else None
@@ -839,7 +839,6 @@ class Heatmap(ImageBase):
x_data (np.ndarray): The x data.
y_data (np.ndarray): The y data.
z_data (np.ndarray): The z data.
msg (messages.ScanStatusMessage): The scan status message.
Returns:
tuple[np.ndarray, QTransform]: The image data and the QTransform.
@@ -854,7 +853,7 @@ class Heatmap(ImageBase):
if len(z_data) < 4:
# LinearNDInterpolator requires at least 4 points to interpolate
return None, None
return self.get_step_scan_image(x_data, y_data, z_data, msg)
return self.get_step_scan_image(x_data, y_data, z_data)
def _is_grid_scan_supported(self, msg: messages.ScanStatusMessage) -> bool:
"""Check if the scan can use optimized grid_scan rendering.
@@ -871,11 +870,11 @@ class Heatmap(ImageBase):
if msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
return False
device_x = self._image_config.x_device.entry
device_y = self._image_config.y_device.entry
signal_x = self._image_config.device_x.signal
signal_y = self._image_config.device_y.signal
return (
device_x in msg.request_inputs["arg_bundle"]
and device_y in msg.request_inputs["arg_bundle"]
signal_x in msg.request_inputs["arg_bundle"]
and signal_y in msg.request_inputs["arg_bundle"]
)
def get_grid_scan_image(
@@ -893,9 +892,9 @@ class Heatmap(ImageBase):
args = self.arg_bundle_to_dict(4, msg.request_inputs["arg_bundle"])
x_entry = self._image_config.x_device.entry
y_entry = self._image_config.y_device.entry
shape = (args[x_entry][-1], args[y_entry][-1])
signal_x = self._image_config.device_x.signal
signal_y = self._image_config.device_y.signal
shape = (args[signal_x][-1], args[signal_y][-1])
data = self.main_image.raw_data
@@ -925,8 +924,8 @@ class Heatmap(ImageBase):
return origin + np.linspace(start, stop, npts)
return np.linspace(start, stop, npts)
x_levels = _axis_levels(x_entry, shape[0])
y_levels = _axis_levels(y_entry, shape[1])
x_levels = _axis_levels(signal_x, shape[0])
y_levels = _axis_levels(signal_y, shape[1])
pixel_size_x = (
float(x_levels[-1] - x_levels[0]) / max(shape[0] - 1, 1) if shape[0] > 1 else 1.0
@@ -949,7 +948,7 @@ class Heatmap(ImageBase):
if snaked and (slow_i % 2 == 1):
fast_i = args[fast_entry][-1] - 1 - fast_i
if x_entry == fast_entry:
if signal_x == fast_entry:
x_i, y_i = fast_i, slow_i
else:
x_i, y_i = slow_i, fast_i
@@ -959,11 +958,7 @@ class Heatmap(ImageBase):
return data, transform
def get_step_scan_image(
self,
x_data: list[float],
y_data: list[float],
z_data: list[float],
msg: messages.ScanStatusMessage,
self, x_data: list[float], y_data: list[float], z_data: list[float]
) -> tuple[np.ndarray, QTransform]:
"""
Get the image data for an arbitrary step scan.
@@ -972,7 +967,6 @@ class Heatmap(ImageBase):
x_data (list[float]): The x data.
y_data (list[float]): The y data.
z_data (list[float]): The z data.
msg (messages.ScanStatusMessage): The scan status message.
Returns:
tuple[np.ndarray, QTransform]: The image data and the QTransform.
@@ -1033,7 +1027,7 @@ class Heatmap(ImageBase):
to avoid recalculating the grid for the same scan.
Args:
_scan_id (str): The scan ID. Needed for caching but not used in the function.
positions: positions of the data points.
Returns:
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
@@ -1108,11 +1102,13 @@ class Heatmap(ImageBase):
return max(1, width_pixels), max(1, height_pixels)
def arg_bundle_to_dict(self, bundle_size: int, args: list) -> dict:
@staticmethod
def arg_bundle_to_dict(bundle_size: int, args: list) -> dict:
"""
Convert the argument bundle to a dictionary.
Args:
bundle_size (int): The size of each argument bundle.
args (list): The argument bundle.
Returns:
@@ -1160,14 +1156,14 @@ class Heatmap(ImageBase):
################################################################################
@SafeProperty(str)
def x_device_name(self) -> str:
def device_x(self) -> str:
"""Device name for the X axis."""
if self._image_config.x_device is None:
if self._image_config.device_x is None:
return ""
return self._image_config.x_device.name or ""
return self._image_config.device_x.device or ""
@x_device_name.setter
def x_device_name(self, device_name: str) -> None:
@device_x.setter
def device_x(self, device_name: str) -> None:
"""
Set the X device name.
@@ -1179,27 +1175,27 @@ class Heatmap(ImageBase):
# Get current entry or validate
if device_name:
try:
entry = self.entry_validator.validate_signal(device_name, None)
self._image_config.x_device = HeatmapDeviceSignal(name=device_name, entry=entry)
self.property_changed.emit("x_device_name", device_name)
signal = self.entry_validator.validate_signal(device_name, None)
self._image_config.device_x = HeatmapDeviceSignal(device=device_name, signal=signal)
self.property_changed.emit("device_x", device_name)
self.update_labels() # Update axis labels
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
self._image_config.x_device = None
self.property_changed.emit("x_device_name", "")
self._image_config.device_x = None
self.property_changed.emit("device_x", "")
self.update_labels() # Clear axis labels
@SafeProperty(str)
def x_device_entry(self) -> str:
def signal_x(self) -> str:
"""Signal entry for the X axis device."""
if self._image_config.x_device is None:
if self._image_config.device_x is None:
return ""
return self._image_config.x_device.entry or ""
return self._image_config.device_x.signal or ""
@x_device_entry.setter
def x_device_entry(self, entry: str) -> None:
@signal_x.setter
def signal_x(self, entry: str) -> None:
"""
Set the X device entry.
@@ -1209,32 +1205,32 @@ class Heatmap(ImageBase):
if not entry:
return
if self._image_config.x_device is None:
logger.warning("Cannot set x_device_entry without x_device_name set first.")
if self._image_config.device_x is None:
logger.warning("Cannot set signal_x without device_x set first.")
return
device_name = self._image_config.x_device.name
device_name = self._image_config.device_x.device
try:
# Validate the entry for this device
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._image_config.x_device = HeatmapDeviceSignal(
name=device_name, entry=validated_entry
validated_signal = self.entry_validator.validate_signal(device_name, entry)
self._image_config.device_x = HeatmapDeviceSignal(
device=device_name, signal=validated_signal
)
self.property_changed.emit("x_device_entry", validated_entry)
self.property_changed.emit("signal_x", validated_signal)
self.update_labels() # Update axis labels
self._try_auto_plot()
except Exception:
pass # Silently fail if validation fails
@SafeProperty(str)
def y_device_name(self) -> str:
def device_y(self) -> str:
"""Device name for the Y axis."""
if self._image_config.y_device is None:
if self._image_config.device_y is None:
return ""
return self._image_config.y_device.name or ""
return self._image_config.device_y.device or ""
@y_device_name.setter
def y_device_name(self, device_name: str) -> None:
@device_y.setter
def device_y(self, device_name: str) -> None:
"""
Set the Y device name.
@@ -1246,27 +1242,27 @@ class Heatmap(ImageBase):
# Get current entry or validate
if device_name:
try:
entry = self.entry_validator.validate_signal(device_name, None)
self._image_config.y_device = HeatmapDeviceSignal(name=device_name, entry=entry)
self.property_changed.emit("y_device_name", device_name)
signal = self.entry_validator.validate_signal(device_name, None)
self._image_config.device_y = HeatmapDeviceSignal(device=device_name, signal=signal)
self.property_changed.emit("device_y", device_name)
self.update_labels() # Update axis labels
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
self._image_config.y_device = None
self.property_changed.emit("y_device_name", "")
self._image_config.device_y = None
self.property_changed.emit("device_y", "")
self.update_labels() # Clear axis labels
@SafeProperty(str)
def y_device_entry(self) -> str:
def signal_y(self) -> str:
"""Signal entry for the Y axis device."""
if self._image_config.y_device is None:
if self._image_config.device_y is None:
return ""
return self._image_config.y_device.entry or ""
return self._image_config.device_y.signal or ""
@y_device_entry.setter
def y_device_entry(self, entry: str) -> None:
@signal_y.setter
def signal_y(self, entry: str) -> None:
"""
Set the Y device entry.
@@ -1276,18 +1272,18 @@ class Heatmap(ImageBase):
if not entry:
return
if self._image_config.y_device is None:
logger.warning("Cannot set y_device_entry without y_device_name set first.")
if self._image_config.device_y is None:
logger.warning("Cannot set signal_y without device_y set first.")
return
device_name = self._image_config.y_device.name
device_name = self._image_config.device_y.device
try:
# Validate the entry for this device
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._image_config.y_device = HeatmapDeviceSignal(
name=device_name, entry=validated_entry
validated_signal = self.entry_validator.validate_signal(device_name, entry)
self._image_config.device_y = HeatmapDeviceSignal(
device=device_name, signal=validated_signal
)
self.property_changed.emit("y_device_entry", validated_entry)
self.property_changed.emit("signal_y", validated_signal)
self.update_labels() # Update axis labels
self._try_auto_plot()
except Exception as e:
@@ -1295,14 +1291,14 @@ class Heatmap(ImageBase):
pass # Silently fail if validation fails
@SafeProperty(str)
def z_device_name(self) -> str:
def device_z(self) -> str:
"""Device name for the Z (color) axis."""
if self._image_config.z_device is None:
if self._image_config.device_z is None:
return ""
return self._image_config.z_device.name or ""
return self._image_config.device_z.device or ""
@z_device_name.setter
def z_device_name(self, device_name: str) -> None:
@device_z.setter
def device_z(self, device_name: str) -> None:
"""
Set the Z device name.
@@ -1314,28 +1310,28 @@ class Heatmap(ImageBase):
# Get current entry or validate
if device_name:
try:
entry = self.entry_validator.validate_signal(device_name, None)
self._image_config.z_device = HeatmapDeviceSignal(name=device_name, entry=entry)
self.property_changed.emit("z_device_name", device_name)
signal = self.entry_validator.validate_signal(device_name, None)
self._image_config.device_z = HeatmapDeviceSignal(device=device_name, signal=signal)
self.property_changed.emit("device_z", device_name)
self.update_labels() # Update axis labels (title)
self._try_auto_plot()
except Exception as e:
logger.debug(f"Z device name validation failed: {e}")
pass # Silently fail if device is not available yet
else:
self._image_config.z_device = None
self.property_changed.emit("z_device_name", "")
self._image_config.device_z = None
self.property_changed.emit("device_z", "")
self.update_labels() # Clear axis labels
@SafeProperty(str)
def z_device_entry(self) -> str:
def signal_z(self) -> str:
"""Signal entry for the Z (color) axis device."""
if self._image_config.z_device is None:
if self._image_config.device_z is None:
return ""
return self._image_config.z_device.entry or ""
return self._image_config.device_z.signal or ""
@z_device_entry.setter
def z_device_entry(self, entry: str) -> None:
@signal_z.setter
def signal_z(self, entry: str) -> None:
"""
Set the Z device entry.
@@ -1345,18 +1341,18 @@ class Heatmap(ImageBase):
if not entry:
return
if self._image_config.z_device is None:
logger.warning("Cannot set z_device_entry without z_device_name set first.")
if self._image_config.device_z is None:
logger.warning("Cannot set signal_z without device_z set first.")
return
device_name = self._image_config.z_device.name
device_name = self._image_config.device_z.device
try:
# Validate the entry for this device
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._image_config.z_device = HeatmapDeviceSignal(
name=device_name, entry=validated_entry
validated_signal = self.entry_validator.validate_signal(device_name, entry)
self._image_config.device_z = HeatmapDeviceSignal(
device=device_name, signal=validated_signal
)
self.property_changed.emit("z_device_entry", validated_entry)
self.property_changed.emit("signal_z", validated_signal)
self.update_labels() # Update axis labels (title)
self._try_auto_plot()
except Exception as e:
@@ -1368,25 +1364,25 @@ class Heatmap(ImageBase):
Attempt to automatically call plot() if all three devices are set.
Similar to waveform's approach but requires all three devices.
"""
has_x = self._image_config.x_device is not None
has_y = self._image_config.y_device is not None
has_z = self._image_config.z_device is not None
has_x = self._image_config.device_x is not None
has_y = self._image_config.device_y is not None
has_z = self._image_config.device_z is not None
if has_x and has_y and has_z:
x_name = self._image_config.x_device.name
x_entry = self._image_config.x_device.entry
y_name = self._image_config.y_device.name
y_entry = self._image_config.y_device.entry
z_name = self._image_config.z_device.name
z_entry = self._image_config.z_device.entry
device_x = self._image_config.device_x.device
signal_x = self._image_config.device_x.signal
device_y = self._image_config.device_y.device
signal_y = self._image_config.device_y.signal
device_z = self._image_config.device_z.device
signal_z = self._image_config.device_z.signal
try:
self.plot(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
device_x=device_x,
device_y=device_y,
device_z=device_z,
signal_x=signal_x,
signal_y=signal_y,
signal_z=signal_z,
validate_bec=False, # Don't validate - entries already validated
)
except Exception as e:
@@ -1533,6 +1529,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
heatmap = Heatmap()
heatmap.plot(x_name="samx", y_name="samy", z_name="bpm4i", oversampling_factor=5.0)
heatmap.plot(device_x="samx", device_y="samy", device_z="bpm4i", oversampling_factor=5.0)
heatmap.show()
sys.exit(app.exec_())

View File

@@ -48,7 +48,7 @@ class HeatmapSettings(SettingWidget):
if popup is False:
self.ui.button_apply.clicked.connect(self.accept_changes)
self.ui.x_name.setFocus()
self.ui.device_x.setFocus()
@SafeSlot()
def fetch_all_properties(self):
@@ -62,44 +62,44 @@ class HeatmapSettings(SettingWidget):
color_map = getattr(self.target_widget, "color_map", None)
# Default values for device properties
x_name, x_entry = None, None
y_name, y_entry = None, None
z_name, z_entry = None, None
device_x, signal_x = None, None
device_y, signal_y = None, None
device_z, signal_z = None, None
# Safely access device properties
if hasattr(self.target_widget, "_image_config") and self.target_widget._image_config:
config = self.target_widget._image_config
if hasattr(config, "x_device") and config.x_device:
x_name = getattr(config.x_device, "name", None)
x_entry = getattr(config.x_device, "entry", None)
if hasattr(config, "device_x") and config.device_x:
device_x = getattr(config.device_x, "device", None)
signal_x = getattr(config.device_x, "signal", None)
if hasattr(config, "y_device") and config.y_device:
y_name = getattr(config.y_device, "name", None)
y_entry = getattr(config.y_device, "entry", None)
if hasattr(config, "device_y") and config.device_y:
device_y = getattr(config.device_y, "device", None)
signal_y = getattr(config.device_y, "signal", None)
if hasattr(config, "z_device") and config.z_device:
z_name = getattr(config.z_device, "name", None)
z_entry = getattr(config.z_device, "entry", None)
if hasattr(config, "device_z") and config.device_z:
device_z = getattr(config.device_z, "device", None)
signal_z = getattr(config.device_z, "signal", None)
# Apply the properties to the settings widget
if hasattr(self.ui, "color_map"):
self.ui.color_map.colormap = color_map
if hasattr(self.ui, "x_name"):
self.ui.x_name.set_device(x_name)
if hasattr(self.ui, "x_entry") and x_entry is not None:
self.ui.x_entry.set_to_obj_name(x_entry)
if hasattr(self.ui, "device_x"):
self.ui.device_x.set_device(device_x)
if hasattr(self.ui, "signal_x") and signal_x is not None:
self.ui.signal_x.set_to_obj_name(signal_x)
if hasattr(self.ui, "y_name"):
self.ui.y_name.set_device(y_name)
if hasattr(self.ui, "y_entry") and y_entry is not None:
self.ui.y_entry.set_to_obj_name(y_entry)
if hasattr(self.ui, "device_y"):
self.ui.device_y.set_device(device_y)
if hasattr(self.ui, "signal_y") and signal_y is not None:
self.ui.signal_y.set_to_obj_name(signal_y)
if hasattr(self.ui, "z_name"):
self.ui.z_name.set_device(z_name)
if hasattr(self.ui, "z_entry") and z_entry is not None:
self.ui.z_entry.set_to_obj_name(z_entry)
if hasattr(self.ui, "device_z"):
self.ui.device_z.set_device(device_z)
if hasattr(self.ui, "signal_z") and signal_z is not None:
self.ui.signal_z.set_to_obj_name(signal_z)
if hasattr(self.ui, "interpolation"):
self.ui.interpolation.setCurrentText(
@@ -119,12 +119,12 @@ class HeatmapSettings(SettingWidget):
"""
Apply all properties from the settings widget to the target widget.
"""
x_name = self.ui.x_name.currentText()
x_entry = self.ui.x_entry.get_signal_name()
y_name = self.ui.y_name.currentText()
y_entry = self.ui.y_entry.get_signal_name()
z_name = self.ui.z_name.currentText()
z_entry = self.ui.z_entry.get_signal_name()
device_x = self.ui.device_x.currentText()
signal_x = self.ui.signal_x.get_signal_name()
device_y = self.ui.device_y.currentText()
signal_y = self.ui.signal_y.get_signal_name()
device_z = self.ui.device_z.currentText()
signal_z = self.ui.signal_z.get_signal_name()
validate_bec = self.ui.validate_bec.checked
color_map = self.ui.color_map.colormap
interpolation = self.ui.interpolation.currentText()
@@ -132,12 +132,12 @@ class HeatmapSettings(SettingWidget):
enforce_interpolation = self.ui.enforce_interpolation.isChecked()
self.target_widget.plot(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
device_x=device_x,
device_y=device_y,
device_z=device_z,
signal_x=signal_x,
signal_y=signal_y,
signal_z=signal_z,
color_map=color_map,
validate_bec=validate_bec,
interpolation=interpolation,
@@ -147,17 +147,17 @@ class HeatmapSettings(SettingWidget):
)
def cleanup(self):
self.ui.x_name.close()
self.ui.x_name.deleteLater()
self.ui.x_entry.close()
self.ui.x_entry.deleteLater()
self.ui.y_name.close()
self.ui.y_name.deleteLater()
self.ui.y_entry.close()
self.ui.y_entry.deleteLater()
self.ui.z_name.close()
self.ui.z_name.deleteLater()
self.ui.z_entry.close()
self.ui.z_entry.deleteLater()
self.ui.device_x.close()
self.ui.device_x.deleteLater()
self.ui.signal_x.close()
self.ui.signal_x.deleteLater()
self.ui.device_y.close()
self.ui.device_y.deleteLater()
self.ui.signal_y.close()
self.ui.signal_y.deleteLater()
self.ui.device_z.close()
self.ui.device_z.deleteLater()
self.ui.signal_z.close()
self.ui.signal_z.deleteLater()
self.ui.interpolation.close()
self.ui.interpolation.deleteLater()

View File

@@ -196,7 +196,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="x_name">
<widget class="DeviceComboBox" name="device_x">
<property name="editable">
<bool>true</bool>
</property>
@@ -206,7 +206,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="x_entry">
<widget class="SignalComboBox" name="signal_x">
<property name="editable">
<bool>true</bool>
</property>
@@ -236,7 +236,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="y_name">
<widget class="DeviceComboBox" name="device_y">
<property name="editable">
<bool>true</bool>
</property>
@@ -246,7 +246,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="y_entry">
<widget class="SignalComboBox" name="signal_y">
<property name="editable">
<bool>true</bool>
</property>
@@ -276,7 +276,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="z_name">
<widget class="DeviceComboBox" name="device_z">
<property name="editable">
<bool>true</bool>
</property>
@@ -286,7 +286,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="z_entry">
<widget class="SignalComboBox" name="signal_z">
<property name="editable">
<bool>true</bool>
</property>
@@ -322,21 +322,21 @@
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>y_name</tabstop>
<tabstop>z_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_entry</tabstop>
<tabstop>device_x</tabstop>
<tabstop>device_y</tabstop>
<tabstop>device_z</tabstop>
<tabstop>signal_x</tabstop>
<tabstop>signal_y</tabstop>
<tabstop>signal_z</tabstop>
<tabstop>interpolation</tabstop>
<tabstop>oversampling_factor</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>device_reset()</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -350,9 +350,9 @@
</hints>
</connection>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
@@ -366,9 +366,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>device_reset()</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -382,9 +382,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
@@ -398,9 +398,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>device_reset()</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -414,9 +414,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">

View File

@@ -69,7 +69,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="x_name">
<widget class="DeviceComboBox" name="device_x">
<property name="editable">
<bool>true</bool>
</property>
@@ -79,7 +79,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="x_entry">
<widget class="SignalComboBox" name="signal_x">
<property name="editable">
<bool>true</bool>
</property>
@@ -109,7 +109,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="y_name">
<widget class="DeviceComboBox" name="device_y">
<property name="editable">
<bool>true</bool>
</property>
@@ -119,7 +119,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="y_entry">
<widget class="SignalComboBox" name="signal_y">
<property name="editable">
<bool>true</bool>
</property>
@@ -142,7 +142,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="z_name">
<widget class="DeviceComboBox" name="device_z">
<property name="editable">
<bool>true</bool>
</property>
@@ -152,7 +152,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="z_entry">
<widget class="SignalComboBox" name="signal_z">
<property name="editable">
<bool>true</bool>
</property>
@@ -264,20 +264,20 @@
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>y_name</tabstop>
<tabstop>z_name</tabstop>
<tabstop>device_x</tabstop>
<tabstop>device_y</tabstop>
<tabstop>device_z</tabstop>
<tabstop>button_apply</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_entry</tabstop>
<tabstop>signal_x</tabstop>
<tabstop>signal_y</tabstop>
<tabstop>signal_z</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>device_reset()</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -291,9 +291,9 @@
</hints>
</connection>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
@@ -307,9 +307,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>device_reset()</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -323,9 +323,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
@@ -339,9 +339,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>device_reset()</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -355,9 +355,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">

View File

@@ -42,8 +42,8 @@ class ImageConfig(ConnectionConfig):
class ImageLayerConfig(BaseModel):
device_name: str = Field("", description="The device name to monitor.")
device_entry: str = Field("", description="The signal/entry name to monitor on the device.")
device: str = Field("", description="The device name to monitor.")
signal: str = Field("", description="The signal/entry name to monitor on the device.")
monitor_type: Literal["1d", "2d"] | None = Field(None, description="The type of monitor.")
source: Literal["device_monitor_1d", "device_monitor_2d"] | None = Field(
None, description="The source of the image data."
@@ -80,10 +80,10 @@ class Image(ImageBase):
"autorange.setter",
"autorange_mode",
"autorange_mode.setter",
"device_name",
"device_name.setter",
"device_entry",
"device_entry.setter",
"device",
"device.setter",
"signal",
"signal.setter",
"enable_colorbar",
"enable_simple_colorbar",
"enable_simple_colorbar.setter",
@@ -206,19 +206,19 @@ class Image(ImageBase):
signal_text = device_selection.signal_combo_box.currentText()
if not device:
self.device_name = ""
self.device = ""
return
if not device_selection.device_combo_box.is_valid_input:
return
if not device_selection.signal_combo_box.is_valid_input:
if self._config.device_entry:
self.device_entry = ""
if device != self._config.device_name:
self.device_name = device
if self._config.signal:
self.signal = ""
if device != self._config.device:
self.device = device
return
if device == self._config.device_name and signal_text == self._config.device_entry:
if device == self._config.device and signal_text == self._config.signal:
return
# Get the signal config stored in the combobox
@@ -235,8 +235,8 @@ class Image(ImageBase):
# Store signal config and set properties which will trigger the connection
self._signal_configs["main"] = signal_config
self.device_name = device
self.device_entry = signal_text
self.device = device
self.signal = signal_text
finally:
self._device_selection_updating = False
@@ -244,55 +244,53 @@ class Image(ImageBase):
# Data Acquisition
@SafeProperty(str, auto_emit=True)
def device_name(self) -> str:
def device(self) -> str:
"""
The name of the device to monitor for image data.
"""
return self._config.device_name
return self._config.device
@device_name.setter
def device_name(self, value: str):
@device.setter
def device(self, value: str):
"""
Set the device name for the image. This should be used together with device_entry.
When both device_name and device_entry are set, the widget connects to that device signal.
Set the device name for the image. This should be used together with signal.
When both device and signal are set, the widget connects to that device signal.
Args:
value(str): The name of the device to monitor.
"""
if not value:
# Clear the monitor if empty device name
if self._config.device_name:
if self._config.device:
self._disconnect_current_monitor()
self._config.device_name = ""
self._config.device_entry = ""
self._config.device = ""
self._config.signal = ""
self._signal_configs.pop("main", None)
self._set_connection_status("disconnected")
return
old_device = self._config.device_name
self._config.device_name = value
old_device = self._config.device
self._config.device = value
# If we have a device_entry, reconnect with the new device
if self._config.device_entry:
# If we have a signal, reconnect with the new device
if self._config.signal:
# Try to get fresh signal config for the new device
try:
device_obj = self.dev[value]
# Try to get signal config for the current entry
if self._config.device_entry in device_obj._info.get("signals", {}):
self._signal_configs["main"] = device_obj._info["signals"][
self._config.device_entry
]
if self._config.signal in device_obj._info.get("signals", {}):
self._signal_configs["main"] = device_obj._info["signals"][self._config.signal]
self._setup_connection()
else:
# Signal doesn't exist on new device
logger.warning(
f"Signal '{self._config.device_entry}' doesn't exist on device '{value}'"
f"Signal '{self._config.signal}' doesn't exist on device '{value}'"
)
self._disconnect_current_monitor()
self._config.device_entry = ""
self._config.signal = ""
self._signal_configs.pop("main", None)
self._set_connection_status(
"error", f"Signal '{self._config.device_entry}' doesn't exist"
"error", f"Signal '{self._config.signal}' doesn't exist"
)
except (KeyError, AttributeError):
# Device doesn't exist
@@ -304,40 +302,40 @@ class Image(ImageBase):
# Toolbar sync happens via SafeProperty auto_emit property_changed handling.
@SafeProperty(str, auto_emit=True)
def device_entry(self) -> str:
def signal(self) -> str:
"""
The signal/entry name to monitor on the device.
"""
return self._config.device_entry
return self._config.signal
@device_entry.setter
def device_entry(self, value: str):
@signal.setter
def signal(self, value: str):
"""
Set the device entry (signal) for the image. This should be used together with device_name.
Set the device signal for the image. This should be used together with device.
When set, it will connect to updates from that device signal.
Args:
value(str): The signal name to monitor.
"""
if not value:
if self._config.device_entry:
if self._config.signal:
self._disconnect_current_monitor()
self._config.device_entry = ""
self._config.signal = ""
self._signal_configs.pop("main", None)
self._set_connection_status("disconnected")
return
self._config.device_entry = value
self._config.signal = value
# If we have a device_name, try to connect
if self._config.device_name:
# If we have a device, try to connect
if self._config.device:
try:
device_obj = self.dev[self._config.device_name]
device_obj = self.dev[self._config.device]
signal_config = device_obj._info["signals"].get(value)
if not isinstance(signal_config, dict) or not signal_config.get("signal_class"):
logger.warning(
f"Could not find valid configuration for signal '{value}' "
f"on device '{self._config.device_name}'."
f"on device '{self._config.device}'."
)
self._signal_configs.pop("main", None)
self._set_connection_status("error", f"Signal '{value}' not found")
@@ -347,14 +345,14 @@ class Image(ImageBase):
self._setup_connection()
except (KeyError, AttributeError):
logger.warning(
f"Could not find signal '{value}' on device '{self._config.device_name}'."
f"Could not find signal '{value}' on device '{self._config.device}'."
)
# Remove signal config if it can't be fetched
self._signal_configs.pop("main", None)
self._set_connection_status("error", f"Signal '{value}' not found")
else:
logger.debug(f"device_entry setter: No device set yet for signal '{value}'")
logger.debug(f"signal setter: No device set yet for signal '{value}'")
@property
def main_image(self) -> ImageItem:
@@ -363,17 +361,17 @@ class Image(ImageBase):
def _setup_connection(self):
"""
Internal method to setup connection based on current device_name, device_entry, and signal_config.
Internal method to setup connection based on current device, signal, and signal_config.
"""
if not self._config.device_name or not self._config.device_entry:
logger.warning("Cannot setup connection without both device_name and device_entry")
if not self._config.device or not self._config.signal:
logger.warning("Cannot setup connection without both device and signal")
self._set_connection_status("disconnected")
return
signal_config = self._signal_configs.get("main")
if not signal_config:
logger.warning(
f"Cannot setup connection for {self._config.device_name}.{self._config.device_entry} without signal_config"
f"Cannot setup connection for {self._config.device}.{self._config.signal} without signal_config"
)
self._set_connection_status("error", "Missing signal config")
return
@@ -387,7 +385,7 @@ class Image(ImageBase):
if signal_class not in supported_classes:
logger.warning(
f"Signal '{self._config.device_name}.{self._config.device_entry}' has unsupported signal class '{signal_class}'. "
f"Signal '{self._config.device}.{self._config.signal}' has unsupported signal class '{signal_class}'. "
f"Supported classes: {supported_classes}"
)
self._set_connection_status("error", f"Unsupported signal class '{signal_class}'")
@@ -399,7 +397,7 @@ class Image(ImageBase):
if ndim is None:
logger.warning(
f"Signal '{self._config.device_name}.{self._config.device_entry}' does not have a valid 'ndim' in its signal_info."
f"Signal '{self._config.device}.{self._config.signal}' does not have a valid 'ndim' in its signal_info."
)
self._set_connection_status("error", "Missing ndim in signal_info")
return
@@ -414,14 +412,12 @@ class Image(ImageBase):
if signal_class == "PreviewSignal":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d,
MessageEndpoints.device_preview(
self._config.device_name, self._config.device_entry
),
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
elif signal_class in self.SUPPORTED_SIGNALS:
self.async_update = True
config.async_signal_name = signal_config.get(
"obj_name", f"{self._config.device_name}_{self._config.device_entry}"
"obj_name", f"{self._config.device}_{self._config.signal}"
)
self._setup_async_image(self.scan_id)
elif ndim == 2:
@@ -430,26 +426,24 @@ class Image(ImageBase):
if signal_class == "PreviewSignal":
self.bec_dispatcher.connect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(
self._config.device_name, self._config.device_entry
),
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
elif signal_class in self.SUPPORTED_SIGNALS:
self.async_update = True
config.async_signal_name = signal_config.get(
"obj_name", f"{self._config.device_name}_{self._config.device_entry}"
"obj_name", f"{self._config.device}_{self._config.signal}"
)
self._setup_async_image(self.scan_id)
else:
logger.warning(
f"Unsupported ndim '{ndim}' for monitor '{self._config.device_name}.{self._config.device_entry}'."
f"Unsupported ndim '{ndim}' for monitor '{self._config.device}.{self._config.signal}'."
)
self._set_connection_status("error", f"Unsupported ndim '{ndim}'")
return
self._set_connection_status("connected")
logger.info(
f"Connected to {self._config.device_name}.{self._config.device_entry} with type {config.monitor_type}"
f"Connected to {self._config.device}.{self._config.signal} with type {config.monitor_type}"
)
self._autorange_on_next_update = True
@@ -457,13 +451,13 @@ class Image(ImageBase):
"""
Internal method to disconnect the current monitor subscriptions.
"""
if not self._config.device_name or not self._config.device_entry:
if not self._config.device or not self._config.signal:
return
config = self.subscriptions["main"]
if self.async_update:
async_signal_name = config.async_signal_name or self._config.device_entry
async_signal_name = config.async_signal_name or self._config.signal
ids_to_check = [self.scan_id, self.old_scan_id]
if config.source == "device_monitor_1d":
@@ -473,11 +467,11 @@ class Image(ImageBase):
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_async_signal(
scan_id, self._config.device_name, async_signal_name
scan_id, self._config.device, async_signal_name
),
)
logger.info(
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}"
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
)
elif config.source == "device_monitor_2d":
for scan_id in ids_to_check:
@@ -486,33 +480,29 @@ class Image(ImageBase):
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_async_signal(
scan_id, self._config.device_name, async_signal_name
scan_id, self._config.device, async_signal_name
),
)
logger.info(
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device_name},Device Entry:{async_signal_name}"
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
)
else:
if config.source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_preview(
self._config.device_name, self._config.device_entry
),
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
logger.info(
f"Disconnecting preview 1d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}"
f"Disconnecting preview 1d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
)
elif config.source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(
self._config.device_name, self._config.device_entry
),
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
logger.info(
f"Disconnecting preview 2d update Device Name:{self._config.device_name}, Device Entry:{self._config.device_entry}"
f"Disconnecting preview 2d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
)
# Reset async state
@@ -526,8 +516,8 @@ class Image(ImageBase):
@SafeSlot(popup_error=True)
def image(
self,
device_name: str | None = None,
device_entry: str | None = None,
device: str | None = None,
signal: str | None = None,
color_map: str | None = None,
color_bar: Literal["simple", "full"] | None = None,
vrange: tuple[int, int] | None = None,
@@ -536,8 +526,8 @@ class Image(ImageBase):
Set the image source and update the image.
Args:
device_name(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
device_entry(str|None): The signal/entry name to monitor on the device.
device(str|None): The name of the device to monitor. If None or empty string, the current monitor will be disconnected.
signal(str|None): The signal/entry name to monitor on the device.
color_map(str): The color map to use for the image.
color_bar(str): The type of color bar to use. Options are "simple" or "full".
vrange(tuple): The range of values to use for the color map.
@@ -546,27 +536,27 @@ class Image(ImageBase):
ImageItem: The image object, or None if connection failed.
"""
# Disconnect existing monitor if any
if self._config.device_name and self._config.device_entry:
if self._config.device and self._config.signal:
self._disconnect_current_monitor()
if not device_name or not device_entry:
if device_name or device_entry:
logger.warning("Both device_name and device_entry must be specified")
if not device or not signal:
if device or signal:
logger.warning("Both device and signal must be specified")
else:
logger.info("Disconnecting image monitor")
self.device_name = ""
self.device = ""
return None
# Validate device
self.entry_validator.validate_monitor(device_name)
self.entry_validator.validate_monitor(device)
# Clear old entry first to avoid reconnect attempts on the new device
if self._config.device_entry:
self.device_entry = ""
if self._config.signal:
self.signal = ""
# Set properties to trigger connection
self.device_name = device_name
self.device_entry = device_entry
self.device = device
self.signal = signal
# Apply visual settings
if color_map is not None:
@@ -581,7 +571,7 @@ class Image(ImageBase):
def _sync_device_selection(self):
"""
Synchronize the device and signal comboboxes with the current monitor state.
This ensures the toolbar reflects the device_name and device_entry properties.
This ensures the toolbar reflects the device and signal properties.
"""
try:
device_selection_action = self.toolbar.components.get_action("device_selection")
@@ -593,8 +583,8 @@ class Image(ImageBase):
return
device_selection: DeviceSelection = device_selection_action.widget
target_device = self._config.device_name or ""
target_entry = self._config.device_entry or ""
target_device = self._config.device or ""
target_entry = self._config.signal or ""
# Check if already synced
if (
@@ -605,15 +595,15 @@ class Image(ImageBase):
device_selection.set_device_and_signal(target_device, target_entry)
def _sync_device_entry_from_toolbar(self) -> None:
def _sync_signal_from_toolbar(self) -> None:
"""
Pull the signal selection from the toolbar if it differs from the current device_entry.
This keeps CLI-driven device_name updates in sync with the signal combobox state.
Pull the signal selection from the toolbar if it differs from the current signal.
This keeps CLI-driven device updates in sync with the signal combobox state.
"""
if self._device_selection_updating:
return
if not self._config.device_name:
if not self._config.device:
return
try:
@@ -625,17 +615,17 @@ class Image(ImageBase):
return
device_selection: DeviceSelection = device_selection_action.widget
if device_selection.device_combo_box.currentText() != self._config.device_name:
if device_selection.device_combo_box.currentText() != self._config.device:
return
signal_text = device_selection.signal_combo_box.currentText()
if not signal_text or signal_text == self._config.device_entry:
if not signal_text or signal_text == self._config.signal:
return
signal_config = device_selection.signal_combo_box.get_signal_config()
if not signal_config:
try:
device_obj = self.dev[self._config.device_name]
device_obj = self.dev[self._config.device]
signal_config = device_obj._info["signals"].get(signal_text, {})
except (KeyError, AttributeError):
signal_config = None
@@ -646,7 +636,7 @@ class Image(ImageBase):
self._signal_configs["main"] = signal_config
self._device_selection_updating = True
try:
self.device_entry = signal_text
self.signal = signal_text
finally:
self._device_selection_updating = False
@@ -795,17 +785,17 @@ class Image(ImageBase):
def _get_async_signal_name(self) -> tuple[str, str] | None:
"""
Returns device name and async signal name used for endpoints/messages.
Returns device and async signal names used for endpoints/messages.
Returns:
tuple[str, str] | None: (device_name, async_signal_name) or None if not available.
tuple[str, str] | None: (device, async_signal_name) or None if not available.
"""
if not self._config.device_name or not self._config.device_entry:
if not self._config.device or not self._config.signal:
return None
config = self.subscriptions["main"]
async_signal = config.async_signal_name or self._config.device_entry
return self._config.device_name, async_signal
async_signal = config.async_signal_name or self._config.signal
return self._config.device, async_signal
def _setup_async_image(self, scan_id: str | None):
"""
@@ -823,7 +813,7 @@ class Image(ImageBase):
logger.info("Async image setup skipped because monitor information is incomplete.")
return
device_name, async_signal = async_names
device, async_signal = async_names
if config.monitor_type == "1d":
slot = self.on_image_update_1d
elif config.monitor_type == "2d":
@@ -839,7 +829,7 @@ class Image(ImageBase):
if prev_scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
slot, MessageEndpoints.device_async_signal(prev_scan_id, device_name, async_signal)
slot, MessageEndpoints.device_async_signal(prev_scan_id, device, async_signal)
)
if scan_id is None:
@@ -848,26 +838,26 @@ class Image(ImageBase):
self.bec_dispatcher.connect_slot(
slot,
MessageEndpoints.device_async_signal(scan_id, device_name, async_signal),
MessageEndpoints.device_async_signal(scan_id, device, async_signal),
from_start=True,
cb_info={"scan_id": scan_id},
)
logger.info(f"Setup async image for {device_name}.{async_signal} and scan {scan_id}.")
logger.info(f"Setup async image for {device}.{async_signal} and scan {scan_id}.")
def disconnect_monitor(self, device_name: str | None = None, device_entry: str | None = None):
def disconnect_monitor(self, device: str | None = None, signal: str | None = None):
"""
Disconnect the monitor from the image update signals, both 1D and 2D.
Args:
device_name(str|None): The name of the device to disconnect. Defaults to current device.
device_entry(str|None): The signal/entry name to disconnect. Defaults to current entry.
device(str|None): The name of the device to disconnect. Defaults to current device.
signal(str|None): The signal/entry name to disconnect. Defaults to current signal.
"""
config = self.subscriptions["main"]
target_device = device_name or self._config.device_name
target_entry = device_entry or self._config.device_entry
target_device = device or self._config.device
target_entry = signal or self._config.signal
if not target_device or not target_entry:
logger.warning("Cannot disconnect monitor without both device_name and device_entry")
logger.warning("Cannot disconnect monitor without both device and signal")
return
if self.async_update:
@@ -1045,10 +1035,10 @@ class Image(ImageBase):
if layer_name not in self.subscriptions:
return
# For the main layer, disconnect current monitor
if layer_name == "main" and self._config.device_name and self._config.device_entry:
if layer_name == "main" and self._config.device and self._config.signal:
self._disconnect_current_monitor()
self._config.device_name = ""
self._config.device_entry = ""
self._config.device = ""
self._config.signal = ""
self._signal_configs.pop("main", None)
def cleanup(self):
@@ -1058,7 +1048,7 @@ class Image(ImageBase):
self.layer_removed.disconnect(self._on_layer_removed)
# Disconnect current monitor
if self._config.device_name and self._config.device_entry:
if self._config.device and self._config.signal:
self._disconnect_current_monitor()
self.subscriptions.clear()

View File

@@ -792,7 +792,10 @@ class ImageBase(PlotBase):
if self._color_bar:
self._apply_colormap_to_colorbar(self.config.color_map)
except ValidationError:
except ValidationError as exc:
logger.warning(
f"Colormap '{value}' is not available; keeping '{self.config.color_map}'. {exc}"
)
return
@SafeProperty("QPointF")

View File

@@ -30,6 +30,7 @@ class DeviceSelection(QWidget):
self.device_combo_box.setEditable(True)
# Set expanding size policy so it grows with available space
self.device_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.device_combo_box.lineEdit().setPlaceholderText("Select Device")
# Configure SignalComboBox to filter by PreviewSignal and supported async signals
# Also filter by ndim (1D and 2D only) for Image widget
@@ -50,6 +51,7 @@ class DeviceSelection(QWidget):
self.signal_combo_box.setEditable(True)
# Set expanding size policy so it grows with available space
self.signal_combo_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.signal_combo_box.lineEdit().setPlaceholderText("Select Signal")
# Connect comboboxes together
self.device_combo_box.currentTextChanged.connect(self.signal_combo_box.set_device)
@@ -62,32 +64,32 @@ class DeviceSelection(QWidget):
layout.addWidget(self.device_combo_box, stretch=1)
layout.addWidget(self.signal_combo_box, stretch=1)
def set_device_and_signal(self, device_name: str | None, device_entry: str | None) -> None:
def set_device_and_signal(self, device: str | None, signal: str | None) -> None:
"""Set the displayed device and signal without emitting selection signals."""
device_name = device_name or ""
device_entry = device_entry or ""
device = device or ""
signal = signal or ""
self.device_combo_box.blockSignals(True)
self.signal_combo_box.blockSignals(True)
try:
if device_name:
if device:
# Set device in device_combo_box
index = self.device_combo_box.findText(device_name)
index = self.device_combo_box.findText(device)
if index >= 0:
self.device_combo_box.setCurrentIndex(index)
else:
# Device not found in list, but still set it
self.device_combo_box.setCurrentText(device_name)
self.device_combo_box.setCurrentText(device)
# Only update signal combobox device filter if it's actually changing
# This prevents redundant repopulation which can cause duplicates !!!!
current_device = getattr(self.signal_combo_box, "_device", None)
if current_device != device_name:
self.signal_combo_box.set_device(device_name)
if current_device != device:
self.signal_combo_box.set_device(device)
# Sync signal combobox selection
if device_entry:
if signal:
# Try to find the signal by component_name (which is what's displayed)
found = False
for i in range(self.signal_combo_box.count()):
@@ -97,14 +99,14 @@ class DeviceSelection(QWidget):
# Check if this matches our signal
if config_data:
component_name = config_data.get("component_name", "")
if text == component_name or text == device_entry:
if text == component_name or text == signal:
self.signal_combo_box.setCurrentIndex(i)
found = True
break
if not found:
# Fallback: try to match the device_entry directly
index = self.signal_combo_box.findText(device_entry)
# Fallback: try to match the signal directly
index = self.signal_combo_box.findText(signal)
if index >= 0:
self.signal_combo_box.setCurrentIndex(index)
else:
@@ -185,8 +187,8 @@ class DeviceSelectionConnection(BundleConnection):
self.components = components
self.target_widget = target_widget
self._connected = False
self.register_property_sync("device_name", self._sync_from_device_name)
self.register_property_sync("device_entry", self._sync_from_device_entry)
self.register_property_sync("device", self._sync_from_device)
self.register_property_sync("signal", self._sync_from_signal)
self.register_property_sync("connection_status", self._sync_connection_status)
self.register_property_sync("connection_error", self._sync_connection_status)
@@ -220,26 +222,22 @@ class DeviceSelectionConnection(BundleConnection):
self._connected = False
widget.cleanup()
def _sync_from_device_name(self, _):
def _sync_from_device(self, _):
try:
widget = self._widget()
except Exception:
return
widget.set_device_and_signal(
self.target_widget.device_name, self.target_widget.device_entry
)
self.target_widget._sync_device_entry_from_toolbar()
widget.set_device_and_signal(self.target_widget.device, self.target_widget.signal)
self.target_widget._sync_signal_from_toolbar()
def _sync_from_device_entry(self, _):
def _sync_from_signal(self, _):
try:
widget = self._widget()
except Exception:
return
widget.set_device_and_signal(
self.target_widget.device_name, self.target_widget.device_entry
)
widget.set_device_and_signal(self.target_widget.device, self.target_widget.signal)
def _sync_connection_status(self, _):
try:

View File

@@ -48,14 +48,14 @@ class FilledRectItem(pg.GraphicsObject):
class MotorConfig(BaseModel):
name: str | None = Field(None, description="Motor name.")
device: str | None = Field(None, description="Motor name.")
limits: list[float] | None = Field(None, description="Motor limits.")
# noinspection PyDataclass
class MotorMapConfig(ConnectionConfig):
x_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor X name.")
y_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor Y name.")
device_x: MotorConfig = Field(default_factory=MotorConfig, description="Motor X name.")
device_y: MotorConfig = Field(default_factory=MotorConfig, description="Motor Y name.")
color: str | tuple | None = Field(
(255, 255, 255, 255), description="The color of the last point of current position."
)
@@ -109,10 +109,10 @@ class MotorMap(PlotBase):
"map",
"reset_history",
"get_data",
"x_motor",
"x_motor.setter",
"y_motor",
"y_motor.setter",
"device_x",
"device_x.setter",
"device_y",
"device_y.setter",
]
update_signal = Signal()
@@ -208,7 +208,7 @@ class MotorMap(PlotBase):
return
if motor_x != "" and motor_y != "":
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
if motor_x != self.config.device_x.device or motor_y != self.config.device_y.device:
self.map(motor_x, motor_y)
def _add_motor_map_settings(self):
@@ -259,32 +259,32 @@ class MotorMap(PlotBase):
################################################################################
@SafeProperty(str)
def x_motor(self) -> str:
def device_x(self) -> str:
"""Name of the motor shown on the X axis."""
return self.config.x_motor.name or ""
return self.config.device_x.device or ""
@x_motor.setter
def x_motor(self, motor_name: str) -> None:
@device_x.setter
def device_x(self, motor_name: str) -> None:
motor_name = motor_name or ""
if motor_name == (self.config.x_motor.name or ""):
if motor_name == (self.config.device_x.device or ""):
return
if motor_name and self.y_motor:
self.map(motor_name, self.y_motor, suppress_errors=True)
if motor_name and self.device_y:
self.map(motor_name, self.device_y, suppress_errors=True)
return
self._set_motor_name(axis="x", motor_name=motor_name)
@SafeProperty(str)
def y_motor(self) -> str:
def device_y(self) -> str:
"""Name of the motor shown on the Y axis."""
return self.config.y_motor.name or ""
return self.config.device_y.device or ""
@y_motor.setter
def y_motor(self, motor_name: str) -> None:
@device_y.setter
def device_y(self, motor_name: str) -> None:
motor_name = motor_name or ""
if motor_name == (self.config.y_motor.name or ""):
if motor_name == (self.config.device_y.device or ""):
return
if motor_name and self.x_motor:
self.map(self.x_motor, motor_name, suppress_errors=True)
if motor_name and self.device_x:
self.map(self.device_x, motor_name, suppress_errors=True)
return
self._set_motor_name(axis="y", motor_name=motor_name)
@@ -452,13 +452,13 @@ class MotorMap(PlotBase):
Update stored motor name for given axis and optionally refresh the toolbar selection.
"""
motor_name = motor_name or ""
motor_config = self.config.x_motor if axis == "x" else self.config.y_motor
motor_config = self.config.device_x if axis == "x" else self.config.device_y
if motor_config.name == motor_name:
if motor_config.device == motor_name:
return
motor_config.name = motor_name
self.property_changed.emit(f"{axis}_motor", motor_name)
motor_config.device = motor_name
self.property_changed.emit(f"device_{axis}", motor_name)
if sync_toolbar:
self._sync_motor_map_selection_toolbar()
@@ -468,14 +468,14 @@ class MotorMap(PlotBase):
################################################################################
@SafeSlot()
def map(
self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False
self, device_x: str, device_y: str, validate_bec: bool = True, suppress_errors=False
) -> None:
"""
Set the x and y motor names.
Args:
x_name(str): The name of the x motor.
y_name(str): The name of the y motor.
device_x(str): The name of the x motor.
device_y(str): The name of the y motor.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied.
"""
@@ -484,22 +484,22 @@ class MotorMap(PlotBase):
if validate_bec:
if suppress_errors:
try:
self.entry_validator.validate_signal(x_name, None)
self.entry_validator.validate_signal(y_name, None)
self.entry_validator.validate_signal(device_x, None)
self.entry_validator.validate_signal(device_y, None)
except Exception:
return
else:
self.entry_validator.validate_signal(x_name, None)
self.entry_validator.validate_signal(y_name, None)
self.entry_validator.validate_signal(device_x, None)
self.entry_validator.validate_signal(device_y, None)
self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False)
self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False)
self._set_motor_name(axis="x", motor_name=device_x, sync_toolbar=False)
self._set_motor_name(axis="y", motor_name=device_y, sync_toolbar=False)
motor_x_limit = self._get_motor_limit(self.config.x_motor.name)
motor_y_limit = self._get_motor_limit(self.config.y_motor.name)
motor_x_limit = self._get_motor_limit(self.config.device_x.device)
motor_y_limit = self._get_motor_limit(self.config.device_y.device)
self.config.x_motor.limits = motor_x_limit
self.config.y_motor.limits = motor_y_limit
self.config.device_x.limits = motor_x_limit
self.config.device_y.limits = motor_y_limit
# reconnect the signals
self._connect_motor_to_slots()
@@ -574,19 +574,19 @@ class MotorMap(PlotBase):
msg(dict): Message from the device readback.
metadata(dict): Metadata of the message.
"""
x_motor = self.config.x_motor.name
y_motor = self.config.y_motor.name
device_x = self.config.device_x.device
device_y = self.config.device_y.device
if x_motor is None or y_motor is None:
if device_x is None or device_y is None:
return
if x_motor in msg["signals"]:
x = msg["signals"][x_motor]["value"]
if device_x in msg["signals"]:
x = msg["signals"][device_x]["value"]
self._buffer["x"].append(x)
self._buffer["y"].append(self._buffer["y"][-1])
elif y_motor in msg["signals"]:
y = msg["signals"][y_motor]["value"]
elif device_y in msg["signals"]:
y = msg["signals"][device_y]["value"]
self._buffer["y"].append(y)
self._buffer["x"].append(self._buffer["x"][-1])
@@ -597,12 +597,12 @@ class MotorMap(PlotBase):
self._disconnect_current_motors()
endpoints_readback = [
MessageEndpoints.device_readback(self.config.x_motor.name),
MessageEndpoints.device_readback(self.config.y_motor.name),
MessageEndpoints.device_readback(self.config.device_x.device),
MessageEndpoints.device_readback(self.config.device_y.device),
]
endpoints_limits = [
MessageEndpoints.device_limits(self.config.x_motor.name),
MessageEndpoints.device_limits(self.config.y_motor.name),
MessageEndpoints.device_limits(self.config.device_x.device),
MessageEndpoints.device_limits(self.config.device_y.device),
]
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints_readback)
@@ -610,14 +610,14 @@ class MotorMap(PlotBase):
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
if self.config.x_motor.name is not None and self.config.y_motor.name is not None:
if self.config.device_x.device is not None and self.config.device_y.device is not None:
endpoints_readback = [
MessageEndpoints.device_readback(self.config.x_motor.name),
MessageEndpoints.device_readback(self.config.y_motor.name),
MessageEndpoints.device_readback(self.config.device_x.device),
MessageEndpoints.device_readback(self.config.device_y.device),
]
endpoints_limits = [
MessageEndpoints.device_limits(self.config.x_motor.name),
MessageEndpoints.device_limits(self.config.y_motor.name),
MessageEndpoints.device_limits(self.config.device_x.device),
MessageEndpoints.device_limits(self.config.device_y.device),
]
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints_readback)
self.bec_dispatcher.disconnect_slot(self.on_device_limits, endpoints_limits)
@@ -634,8 +634,8 @@ class MotorMap(PlotBase):
msg(dict): Message from the device limits.
metadata(dict): Metadata of the message.
"""
self.config.x_motor.limits = self._get_motor_limit(self.config.x_motor.name)
self.config.y_motor.limits = self._get_motor_limit(self.config.y_motor.name)
self.config.device_x.limits = self._get_motor_limit(self.config.device_x.device)
self.config.device_y.limits = self._get_motor_limit(self.config.device_y.device)
self._swap_limit_map()
def _get_motor_limit(self, motor: str) -> list | None:
@@ -663,8 +663,8 @@ class MotorMap(PlotBase):
Make the motor map.
"""
motor_x_limit = self.config.x_motor.limits
motor_y_limit = self.config.y_motor.limits
motor_x_limit = self.config.device_x.limits
motor_y_limit = self.config.device_y.limits
self._limit_map = self._make_limit_map(motor_x_limit, motor_y_limit)
self.plot_item.addItem(self._limit_map)
@@ -678,10 +678,10 @@ class MotorMap(PlotBase):
# Add the crosshair for initial motor coordinates
initial_position_x = self._get_motor_init_position(
self.config.x_motor.name, self.config.precision
self.config.device_x.device, self.config.precision
)
initial_position_y = self._get_motor_init_position(
self.config.y_motor.name, self.config.precision
self.config.device_y.device, self.config.precision
)
self._buffer["x"] = [initial_position_x]
@@ -693,8 +693,8 @@ class MotorMap(PlotBase):
self._add_coordinates_crosshair(initial_position_x, initial_position_y)
# Set default labels for the plot
self.set_x_label_suffix(f"[{self.config.x_motor.name}-{self.config.x_motor.name}]")
self.set_y_label_suffix(f"[{self.config.y_motor.name}-{self.config.y_motor.name}]")
self.set_x_label_suffix(f"[{self.config.device_x.device}-{self.config.device_x.device}]")
self.set_y_label_suffix(f"[{self.config.device_y.device}-{self.config.device_y.device}]")
self.update_signal.emit()
@@ -794,8 +794,8 @@ class MotorMap(PlotBase):
def _swap_limit_map(self):
"""Swap the limit map."""
self.plot_item.removeItem(self._limit_map)
x_limits = self.config.x_motor.limits
y_limits = self.config.y_motor.limits
x_limits = self.config.device_x.limits
y_limits = self.config.device_y.limits
if x_limits is not None and y_limits is not None:
self._limit_map = self._make_limit_map(x_limits, y_limits)
self._limit_map.setZValue(-1)
@@ -828,8 +828,8 @@ class MotorMap(PlotBase):
if motor_selection_action is None:
return
motor_selection: MotorSelection = motor_selection_action.widget
target_x = self.config.x_motor.name or ""
target_y = self.config.y_motor.name or ""
target_x = self.config.device_x.device or ""
target_y = self.config.device_y.device or ""
if (
motor_selection.motor_x.currentText() == target_x
@@ -864,10 +864,10 @@ class DemoApp(QMainWindow): # pragma: no cover
self.setCentralWidget(self.main_widget)
self.motor_map_popup = MotorMap(popups=True)
self.motor_map_popup.map(x_name="samx", y_name="samy", validate_bec=True)
self.motor_map_popup.map(device_x="samx", device_y="samy", validate_bec=True)
self.motor_map_side = MotorMap(popups=False)
self.motor_map_side.map(x_name="samx", y_name="samy", validate_bec=True)
self.motor_map_side.map(device_x="samx", device_y="samy", validate_bec=True)
self.layout.addWidget(self.motor_map_side)
self.layout.addWidget(self.motor_map_popup)

View File

@@ -20,8 +20,8 @@ logger = bec_logger.logger
class ScatterDeviceSignal(BaseModel):
"""The configuration of a signal in the scatter waveform widget."""
name: str
entry: str
device: str
signal: str
model_config: dict = {"validate_assignment": True}
@@ -40,13 +40,13 @@ class ScatterCurveConfig(ConnectionConfig):
color_map: str | None = Field(
"plasma", description="The color palette of the figure widget.", validate_default=True
)
x_device: ScatterDeviceSignal | None = Field(
device_x: ScatterDeviceSignal | None = Field(
None, description="The x device signal of the scatter waveform."
)
y_device: ScatterDeviceSignal | None = Field(
device_y: ScatterDeviceSignal | None = Field(
None, description="The y device signal of the scatter waveform."
)
z_device: ScatterDeviceSignal | None = Field(
device_z: ScatterDeviceSignal | None = Field(
None, description="The z device signal of the scatter waveform."
)

View File

@@ -49,18 +49,18 @@ class ScatterWaveform(PlotBase):
"update_with_scan_history",
"clear_all",
# Device properties
"x_device_name",
"x_device_name.setter",
"x_device_entry",
"x_device_entry.setter",
"y_device_name",
"y_device_name.setter",
"y_device_entry",
"y_device_entry.setter",
"z_device_name",
"z_device_name.setter",
"z_device_entry",
"z_device_entry.setter",
"device_x",
"device_x.setter",
"signal_x",
"signal_x.setter",
"device_y",
"device_y.setter",
"signal_y",
"signal_y.setter",
"device_z",
"device_z.setter",
"signal_z",
"signal_z.setter",
]
sync_signal_update = Signal()
@@ -208,12 +208,12 @@ class ScatterWaveform(PlotBase):
@SafeSlot(popup_error=True)
def plot(
self,
x_name: str,
y_name: str,
z_name: str,
x_entry: None | str = None,
y_entry: None | str = None,
z_entry: None | str = None,
device_x: str,
device_y: str,
device_z: str,
signal_x: None | str = None,
signal_y: None | str = None,
signal_z: None | str = None,
color_map: str | None = "plasma",
label: str | None = None,
validate_bec: bool = True,
@@ -222,12 +222,12 @@ class ScatterWaveform(PlotBase):
Plot the data from the device signals.
Args:
x_name (str): The name of the x device signal.
y_name (str): The name of the y device signal.
z_name (str): The name of the z device signal.
x_entry (None | str): The x entry of the device signal.
y_entry (None | str): The y entry of the device signal.
z_entry (None | str): The z entry of the device signal.
device_x (str): The name of the x device signal.
device_y (str): The name of the y device signal.
device_z (str): The name of the z device signal.
signal_x (None | str): The x entry of the device signal.
signal_y (None | str): The y entry of the device signal.
signal_z (None | str): The z entry of the device signal.
color_map (str | None): The color map of the scatter waveform.
label (str | None): The label of the curve.
validate_bec (bool): Whether to validate the device signals with current BEC instance.
@@ -237,9 +237,9 @@ class ScatterWaveform(PlotBase):
"""
if validate_bec:
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
signal_x = self.entry_validator.validate_signal(device_x, signal_x)
signal_y = self.entry_validator.validate_signal(device_y, signal_y)
signal_z = self.entry_validator.validate_signal(device_z, signal_z)
if color_map is not None:
try:
@@ -250,15 +250,15 @@ class ScatterWaveform(PlotBase):
)
if label is None:
label = f"{z_name}-{z_entry}"
label = f"{device_z}-{signal_z}"
config = ScatterCurveConfig(
parent_id=self.gui_id,
label=label,
color_map=color_map,
x_device=ScatterDeviceSignal(name=x_name, entry=x_entry),
y_device=ScatterDeviceSignal(name=y_name, entry=y_entry),
z_device=ScatterDeviceSignal(name=z_name, entry=z_entry),
device_x=ScatterDeviceSignal(device=device_x, signal=signal_x),
device_y=ScatterDeviceSignal(device=device_y, signal=signal_y),
device_z=ScatterDeviceSignal(device=device_z, signal=signal_z),
)
# Add Curve
@@ -350,23 +350,23 @@ class ScatterWaveform(PlotBase):
return "none"
try:
x_name = self._main_curve.config.x_device.name
x_entry = self._main_curve.config.x_device.entry
y_name = self._main_curve.config.y_device.name
y_entry = self._main_curve.config.y_device.entry
z_name = self._main_curve.config.z_device.name
z_entry = self._main_curve.config.z_device.entry
device_x = self._main_curve.config.device_x.device
signal_x = self._main_curve.config.device_x.signal
device_y = self._main_curve.config.device_y.device
signal_y = self._main_curve.config.device_y.signal
device_z = self._main_curve.config.device_z.device
signal_z = self._main_curve.config.device_z.signal
except AttributeError:
return
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
x_data = data.get(device_x, {}).get(signal_x, {}).get(access_key, None)
y_data = data.get(device_y, {}).get(signal_y, {}).get(access_key, None)
z_data = data.get(device_z, {}).get(signal_z, {}).get(access_key, None)
else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
x_data = data.get(device_x, {}).get(signal_x, {}).read().get("value", None)
y_data = data.get(device_y, {}).get(signal_y, {}).read().get("value", None)
z_data = data.get(device_z, {}).get(signal_z, {}).read().get("value", None)
self._main_curve.set_data(x=x_data, y=y_data, z=z_data)
@@ -399,14 +399,14 @@ class ScatterWaveform(PlotBase):
################################################################################
@SafeProperty(str)
def x_device_name(self) -> str:
def device_x(self) -> str:
"""Device name for the X axis."""
if self._main_curve is None or self._main_curve.config.x_device is None:
if self._main_curve is None or self._main_curve.config.device_x is None:
return ""
return self._main_curve.config.x_device.name or ""
return self._main_curve.config.device_x.device or ""
@x_device_name.setter
def x_device_name(self, device_name: str) -> None:
@device_x.setter
def device_x(self, device_name: str) -> None:
"""
Set the X device name.
@@ -419,33 +419,33 @@ class ScatterWaveform(PlotBase):
try:
entry = self.entry_validator.validate_signal(device_name, None)
# Update or create config
if self._main_curve.config.x_device is None:
self._main_curve.config.x_device = ScatterDeviceSignal(
name=device_name, entry=entry
if self._main_curve.config.device_x is None:
self._main_curve.config.device_x = ScatterDeviceSignal(
device=device_name, signal=entry
)
else:
self._main_curve.config.x_device.name = device_name
self._main_curve.config.x_device.entry = entry
self.property_changed.emit("x_device_name", device_name)
self._main_curve.config.device_x.device = device_name
self._main_curve.config.device_x.signal = entry
self.property_changed.emit("device_x", device_name)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
if self._main_curve.config.x_device is not None:
self._main_curve.config.x_device = None
self.property_changed.emit("x_device_name", "")
if self._main_curve.config.device_x is not None:
self._main_curve.config.device_x = None
self.property_changed.emit("device_x", "")
self.update_labels()
@SafeProperty(str)
def x_device_entry(self) -> str:
def signal_x(self) -> str:
"""Signal entry for the X axis device."""
if self._main_curve is None or self._main_curve.config.x_device is None:
if self._main_curve is None or self._main_curve.config.device_x is None:
return ""
return self._main_curve.config.x_device.entry or ""
return self._main_curve.config.device_x.signal or ""
@x_device_entry.setter
def x_device_entry(self, entry: str) -> None:
@signal_x.setter
def signal_x(self, entry: str) -> None:
"""
Set the X device entry.
@@ -455,29 +455,29 @@ class ScatterWaveform(PlotBase):
if not entry:
return
if self._main_curve.config.x_device is None:
logger.warning("Cannot set x_device_entry without x_device_name set first.")
if self._main_curve.config.device_x is None:
logger.warning("Cannot set signal_x without device_x set first.")
return
device_name = self._main_curve.config.x_device.name
device_name = self._main_curve.config.device_x.device
try:
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.x_device.entry = validated_entry
self.property_changed.emit("x_device_entry", validated_entry)
validated_signal = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.device_x.signal = validated_signal
self.property_changed.emit("signal_x", validated_signal)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if validation fails
@SafeProperty(str)
def y_device_name(self) -> str:
def device_y(self) -> str:
"""Device name for the Y axis."""
if self._main_curve is None or self._main_curve.config.y_device is None:
if self._main_curve is None or self._main_curve.config.device_y is None:
return ""
return self._main_curve.config.y_device.name or ""
return self._main_curve.config.device_y.device or ""
@y_device_name.setter
def y_device_name(self, device_name: str) -> None:
@device_y.setter
def device_y(self, device_name: str) -> None:
"""
Set the Y device name.
@@ -490,33 +490,33 @@ class ScatterWaveform(PlotBase):
try:
entry = self.entry_validator.validate_signal(device_name, None)
# Update or create config
if self._main_curve.config.y_device is None:
self._main_curve.config.y_device = ScatterDeviceSignal(
name=device_name, entry=entry
if self._main_curve.config.device_y is None:
self._main_curve.config.device_y = ScatterDeviceSignal(
device=device_name, signal=entry
)
else:
self._main_curve.config.y_device.name = device_name
self._main_curve.config.y_device.entry = entry
self.property_changed.emit("y_device_name", device_name)
self._main_curve.config.device_y.device = device_name
self._main_curve.config.device_y.signal = entry
self.property_changed.emit("device_y", device_name)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
if self._main_curve.config.y_device is not None:
self._main_curve.config.y_device = None
self.property_changed.emit("y_device_name", "")
if self._main_curve.config.device_y is not None:
self._main_curve.config.device_y = None
self.property_changed.emit("device_y", "")
self.update_labels()
@SafeProperty(str)
def y_device_entry(self) -> str:
def signal_y(self) -> str:
"""Signal entry for the Y axis device."""
if self._main_curve is None or self._main_curve.config.y_device is None:
if self._main_curve is None or self._main_curve.config.device_y is None:
return ""
return self._main_curve.config.y_device.entry or ""
return self._main_curve.config.device_y.signal or ""
@y_device_entry.setter
def y_device_entry(self, entry: str) -> None:
@signal_y.setter
def signal_y(self, entry: str) -> None:
"""
Set the Y device entry.
@@ -526,29 +526,29 @@ class ScatterWaveform(PlotBase):
if not entry:
return
if self._main_curve.config.y_device is None:
logger.warning("Cannot set y_device_entry without y_device_name set first.")
if self._main_curve.config.device_y is None:
logger.warning("Cannot set signal_y without device_y set first.")
return
device_name = self._main_curve.config.y_device.name
device_name = self._main_curve.config.device_y.device
try:
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.y_device.entry = validated_entry
self.property_changed.emit("y_device_entry", validated_entry)
validated_signal = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.device_y.signal = validated_signal
self.property_changed.emit("signal_y", validated_signal)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if validation fails
@SafeProperty(str)
def z_device_name(self) -> str:
def device_z(self) -> str:
"""Device name for the Z (color) axis."""
if self._main_curve is None or self._main_curve.config.z_device is None:
if self._main_curve is None or self._main_curve.config.device_z is None:
return ""
return self._main_curve.config.z_device.name or ""
return self._main_curve.config.device_z.device or ""
@z_device_name.setter
def z_device_name(self, device_name: str) -> None:
@device_z.setter
def device_z(self, device_name: str) -> None:
"""
Set the Z device name.
@@ -561,33 +561,33 @@ class ScatterWaveform(PlotBase):
try:
entry = self.entry_validator.validate_signal(device_name, None)
# Update or create config
if self._main_curve.config.z_device is None:
self._main_curve.config.z_device = ScatterDeviceSignal(
name=device_name, entry=entry
if self._main_curve.config.device_z is None:
self._main_curve.config.device_z = ScatterDeviceSignal(
device=device_name, signal=entry
)
else:
self._main_curve.config.z_device.name = device_name
self._main_curve.config.z_device.entry = entry
self.property_changed.emit("z_device_name", device_name)
self._main_curve.config.device_z.device = device_name
self._main_curve.config.device_z.signal = entry
self.property_changed.emit("device_z", device_name)
self.update_labels()
self._try_auto_plot()
except Exception:
pass # Silently fail if device is not available yet
else:
if self._main_curve.config.z_device is not None:
self._main_curve.config.z_device = None
self.property_changed.emit("z_device_name", "")
if self._main_curve.config.device_z is not None:
self._main_curve.config.device_z = None
self.property_changed.emit("device_z", "")
self.update_labels()
@SafeProperty(str)
def z_device_entry(self) -> str:
def signal_z(self) -> str:
"""Signal entry for the Z (color) axis device."""
if self._main_curve is None or self._main_curve.config.z_device is None:
if self._main_curve is None or self._main_curve.config.device_z is None:
return ""
return self._main_curve.config.z_device.entry or ""
return self._main_curve.config.device_z.signal or ""
@z_device_entry.setter
def z_device_entry(self, entry: str) -> None:
@signal_z.setter
def signal_z(self, entry: str) -> None:
"""
Set the Z device entry.
@@ -597,15 +597,15 @@ class ScatterWaveform(PlotBase):
if not entry:
return
if self._main_curve.config.z_device is None:
logger.warning("Cannot set z_device_entry without z_device_name set first.")
if self._main_curve.config.device_z is None:
logger.warning("Cannot set signal_z without device_z set first.")
return
device_name = self._main_curve.config.z_device.name
device_name = self._main_curve.config.device_z.device
try:
validated_entry = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.z_device.entry = validated_entry
self.property_changed.emit("z_device_entry", validated_entry)
validated_signal = self.entry_validator.validate_signal(device_name, entry)
self._main_curve.config.device_z.signal = validated_signal
self.property_changed.emit("signal_z", validated_signal)
self.update_labels()
self._try_auto_plot()
except Exception:
@@ -615,25 +615,25 @@ class ScatterWaveform(PlotBase):
"""
Attempt to automatically call plot() if all three devices are set.
"""
has_x = self._main_curve.config.x_device is not None
has_y = self._main_curve.config.y_device is not None
has_z = self._main_curve.config.z_device is not None
has_x = self._main_curve.config.device_x is not None
has_y = self._main_curve.config.device_y is not None
has_z = self._main_curve.config.device_z is not None
if has_x and has_y and has_z:
x_name = self._main_curve.config.x_device.name
x_entry = self._main_curve.config.x_device.entry
y_name = self._main_curve.config.y_device.name
y_entry = self._main_curve.config.y_device.entry
z_name = self._main_curve.config.z_device.name
z_entry = self._main_curve.config.z_device.entry
device_x = self._main_curve.config.device_x.device
signal_x = self._main_curve.config.device_x.signal
device_y = self._main_curve.config.device_y.device
signal_y = self._main_curve.config.device_y.signal
device_z = self._main_curve.config.device_z.device
signal_z = self._main_curve.config.device_z.signal
try:
self.plot(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
device_x=device_x,
device_y=device_y,
device_z=device_z,
signal_x=signal_x,
signal_y=signal_y,
signal_z=signal_z,
validate_bec=False, # Don't validate - entries already validated
)
except Exception as e:
@@ -650,21 +650,21 @@ class ScatterWaveform(PlotBase):
config = self._main_curve.config
# Safely get device names
x_device = config.x_device
y_device = config.y_device
device_x = config.device_x
device_y = config.device_y
x_name = x_device.name if x_device else None
y_name = y_device.name if y_device else None
device_x = device_x.device if device_x else None
device_y = device_y.device if device_y else None
if x_name is not None:
self.x_label = x_name # type: ignore
x_dev = self.dev.get(x_name)
if device_x is not None:
self.x_label = device_x # type: ignore
x_dev = self.dev.get(device_x)
if x_dev and hasattr(x_dev, "egu"):
self.x_label_units = x_dev.egu()
if y_name is not None:
self.y_label = y_name # type: ignore
y_dev = self.dev.get(y_name)
if device_y is not None:
self.y_label = device_y # type: ignore
y_dev = self.dev.get(device_y)
if y_dev and hasattr(y_dev, "egu"):
self.y_label_units = y_dev.egu()
@@ -756,7 +756,7 @@ class DemoApp(QMainWindow): # pragma: no cover
self.setCentralWidget(self.main_widget)
self.waveform_popup = ScatterWaveform(popups=True)
self.waveform_popup.plot("samx", "samy", "bpm4i")
self.waveform_popup.plot(device_x="samx", device_y="samy", device_z="bpm4i")
self.waveform_side = ScatterWaveform(popups=False)
self.waveform_popup.plot("samx", "samy", "bpm3a")

View File

@@ -58,81 +58,81 @@ class ScatterCurveSettings(SettingWidget):
color_map = getattr(self.target_widget, "color_map", None)
# Default values for device properties
x_name, x_entry = None, None
y_name, y_entry = None, None
z_name, z_entry = None, None
device_x, signal_x = None, None
device_y, signal_y = None, None
device_z, signal_z = None, None
# Safely access device properties
if hasattr(self.target_widget, "main_curve") and self.target_widget.main_curve:
if hasattr(self.target_widget.main_curve, "config"):
config = self.target_widget.main_curve.config
if hasattr(config, "x_device") and config.x_device:
x_name = getattr(config.x_device, "name", None)
x_entry = getattr(config.x_device, "entry", None)
if hasattr(config, "device_x") and config.device_x:
device_x = getattr(config.device_x, "device", None)
signal_x = getattr(config.device_x, "signal", None)
if hasattr(config, "y_device") and config.y_device:
y_name = getattr(config.y_device, "name", None)
y_entry = getattr(config.y_device, "entry", None)
if hasattr(config, "device_y") and config.device_y:
device_y = getattr(config.device_y, "device", None)
signal_y = getattr(config.device_y, "signal", None)
if hasattr(config, "z_device") and config.z_device:
z_name = getattr(config.z_device, "name", None)
z_entry = getattr(config.z_device, "entry", None)
if hasattr(config, "device_z") and config.device_z:
device_z = getattr(config.device_z, "device", None)
signal_z = getattr(config.device_z, "signal", None)
# Apply the properties to the settings widget
if hasattr(self.ui, "color_map"):
self.ui.color_map.colormap = color_map
if hasattr(self.ui, "x_name"):
self.ui.x_name.set_device(x_name)
if hasattr(self.ui, "x_entry") and x_entry is not None:
self.ui.x_entry.set_to_obj_name(x_entry)
if hasattr(self.ui, "device_x"):
self.ui.device_x.set_device(device_x)
if hasattr(self.ui, "signal_x") and signal_x is not None:
self.ui.signal_x.set_to_obj_name(signal_x)
if hasattr(self.ui, "y_name"):
self.ui.y_name.set_device(y_name)
if hasattr(self.ui, "y_entry") and y_entry is not None:
self.ui.y_entry.set_to_obj_name(y_entry)
if hasattr(self.ui, "device_y"):
self.ui.device_y.set_device(device_y)
if hasattr(self.ui, "signal_y") and signal_y is not None:
self.ui.signal_y.set_to_obj_name(signal_y)
if hasattr(self.ui, "z_name"):
self.ui.z_name.set_device(z_name)
if hasattr(self.ui, "z_entry") and z_entry is not None:
self.ui.z_entry.set_to_obj_name(z_entry)
if hasattr(self.ui, "device_z"):
self.ui.device_z.set_device(device_z)
if hasattr(self.ui, "signal_z") and signal_z is not None:
self.ui.signal_z.set_to_obj_name(signal_z)
@SafeSlot()
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
x_name = self.ui.x_name.currentText()
x_entry = self.ui.x_entry.get_signal_name()
y_name = self.ui.y_name.currentText()
y_entry = self.ui.y_entry.get_signal_name()
z_name = self.ui.z_name.currentText()
z_entry = self.ui.z_entry.get_signal_name()
device_x = self.ui.device_x.currentText()
signal_x = self.ui.signal_x.get_signal_name()
device_y = self.ui.device_y.currentText()
signal_y = self.ui.signal_y.get_signal_name()
device_z = self.ui.device_z.currentText()
signal_z = self.ui.signal_z.get_signal_name()
validate_bec = self.ui.validate_bec.checked
color_map = self.ui.color_map.colormap
self.target_widget.plot(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
device_x=device_x,
device_y=device_y,
device_z=device_z,
signal_x=signal_x,
signal_y=signal_y,
signal_z=signal_z,
color_map=color_map,
validate_bec=validate_bec,
)
def cleanup(self):
self.ui.x_name.close()
self.ui.x_name.deleteLater()
self.ui.x_entry.close()
self.ui.x_entry.deleteLater()
self.ui.y_name.close()
self.ui.y_name.deleteLater()
self.ui.y_entry.close()
self.ui.y_entry.deleteLater()
self.ui.z_name.close()
self.ui.z_name.deleteLater()
self.ui.z_entry.close()
self.ui.z_entry.deleteLater()
self.ui.device_x.close()
self.ui.device_x.deleteLater()
self.ui.signal_x.close()
self.ui.signal_x.deleteLater()
self.ui.device_y.close()
self.ui.device_y.deleteLater()
self.ui.signal_y.close()
self.ui.signal_y.deleteLater()
self.ui.device_z.close()
self.ui.device_z.deleteLater()
self.ui.signal_z.close()
self.ui.signal_z.deleteLater()

View File

@@ -61,7 +61,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="x_name">
<widget class="DeviceComboBox" name="device_x">
<property name="editable">
<bool>true</bool>
</property>
@@ -71,7 +71,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="x_entry">
<widget class="SignalComboBox" name="signal_x">
<property name="editable">
<bool>true</bool>
</property>
@@ -101,7 +101,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="y_name">
<widget class="DeviceComboBox" name="device_y">
<property name="editable">
<bool>true</bool>
</property>
@@ -111,7 +111,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="y_entry">
<widget class="SignalComboBox" name="signal_y">
<property name="editable">
<bool>true</bool>
</property>
@@ -141,7 +141,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="z_name">
<widget class="DeviceComboBox" name="device_z">
<property name="editable">
<bool>true</bool>
</property>
@@ -151,7 +151,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="z_entry">
<widget class="SignalComboBox" name="signal_z">
<property name="editable">
<bool>true</bool>
</property>
@@ -187,19 +187,19 @@
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>y_name</tabstop>
<tabstop>z_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_entry</tabstop>
<tabstop>device_x</tabstop>
<tabstop>device_y</tabstop>
<tabstop>device_z</tabstop>
<tabstop>signal_x</tabstop>
<tabstop>signal_y</tabstop>
<tabstop>signal_z</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>device_reset()</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -213,9 +213,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>device_reset()</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -229,9 +229,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>device_reset()</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
@@ -245,9 +245,9 @@
</hints>
</connection>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
@@ -261,9 +261,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
@@ -277,9 +277,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">

View File

@@ -58,7 +58,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="x_name"/>
<widget class="DeviceLineEdit" name="device_x"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
@@ -68,7 +68,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="x_entry"/>
<widget class="QLineEdit" name="signal_x"/>
</item>
</layout>
</widget>
@@ -87,7 +87,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="y_name"/>
<widget class="DeviceLineEdit" name="device_y"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
@@ -97,7 +97,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="y_entry"/>
<widget class="QLineEdit" name="signal_y"/>
</item>
</layout>
</widget>
@@ -116,7 +116,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="z_name"/>
<widget class="DeviceLineEdit" name="device_z"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
@@ -126,7 +126,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="z_entry"/>
<widget class="QLineEdit" name="signal_z"/>
</item>
</layout>
</widget>
@@ -153,9 +153,9 @@
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<sender>device_x</sender>
<signal>textChanged(QString)</signal>
<receiver>x_entry</receiver>
<receiver>signal_x</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
@@ -169,9 +169,9 @@
</hints>
</connection>
<connection>
<sender>y_name</sender>
<sender>device_y</sender>
<signal>textChanged(QString)</signal>
<receiver>y_entry</receiver>
<receiver>signal_y</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
@@ -185,9 +185,9 @@
</hints>
</connection>
<connection>
<sender>z_name</sender>
<sender>device_z</sender>
<signal>textChanged(QString)</signal>
<receiver>z_entry</receiver>
<receiver>signal_z</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">

View File

@@ -20,8 +20,8 @@ logger = bec_logger.logger
class DeviceSignal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
name: str
entry: str
device: str
signal: str
dap: str | list[str] | None = None
dap_oversample: int = 1
dap_parameters: dict | list | None = None

View File

@@ -140,7 +140,7 @@ class CurveSetting(SettingWidget):
signal_x = self.signal_x.currentText()
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
if signal_x != "":
self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
self.target_widget.signal_x = signal_data.get("obj_name", signal_x)
else:
self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json()

View File

@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QApplication
class ScanIndexValidator(QValidator):
@@ -226,7 +225,7 @@ class CurveRow(QTreeWidgetItem):
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
if self.config.signal:
device_index = self.device_edit.findText(self.config.signal.name or "")
device_index = self.device_edit.findText(self.config.signal.device or "")
if device_index >= 0:
self.device_edit.setCurrentIndex(device_index)
# Force the entry_edit to update based on the device name
@@ -235,7 +234,7 @@ class CurveRow(QTreeWidgetItem):
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
if not self.entry_edit.set_to_obj_name(self.config.signal.entry):
if not self.entry_edit.set_to_obj_name(self.config.signal.signal):
# If the entry is not found, try to set it to the first enabled item
if not self.entry_edit.set_to_first_enabled():
# If no enabled item is found, set to the first item
@@ -309,15 +308,15 @@ class CurveRow(QTreeWidgetItem):
dev_name = ""
dev_entry = ""
if self.config.signal:
dev_name = self.config.signal.name
dev_entry = self.config.signal.entry
dev_name = self.config.signal.device
dev_entry = self.config.signal.signal
# Create a new config for the DAP row
dap_cfg = CurveConfig(
widget_class="Curve",
source="dap",
parent_label=parent_label,
signal=DeviceSignal(name=dev_name, entry=dev_entry),
signal=DeviceSignal(device=dev_name, signal=dev_entry),
)
new_dap = CurveRow(self.tree, parent_item=self, config=dap_cfg, device_manager=self.dev)
# Expand device row to show new child
@@ -395,10 +394,10 @@ class CurveRow(QTreeWidgetItem):
device_entry = device_entry_info.get("obj_name", device_entry)
else:
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=device_entry
device=device_name, signal=device_entry
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.signal = DeviceSignal(device=device_name, signal=device_entry)
scan_combo_text = self.scan_index_combo.currentText()
if scan_combo_text == "live" or scan_combo_text == "":
self.config.scan_number = None
@@ -422,16 +421,16 @@ class CurveRow(QTreeWidgetItem):
if self.parent_item:
parent_conf_dict = self.parent_item.export_data()
parent_conf = CurveConfig(**parent_conf_dict)
dev_name = ""
dev_entry = ""
device = ""
signal = ""
if parent_conf.signal:
dev_name = parent_conf.signal.name
dev_entry = parent_conf.signal.entry
device = parent_conf.signal.device
signal = parent_conf.signal.signal
# Dap from the DapComboBox
new_dap = "GaussianModel"
if hasattr(self, "dap_combo"):
new_dap = self.dap_combo.fit_model_combobox.currentText()
self.config.signal = DeviceSignal(name=dev_name, entry=dev_entry, dap=new_dap)
self.config.signal = DeviceSignal(device=device, signal=signal, dap=new_dap)
self.config.source = "dap"
self.config.parent_label = parent_conf.label
self.config.label = f"{parent_conf.label}-{new_dap}"
@@ -613,15 +612,12 @@ class CurveTree(BECWidget, QWidget):
item.config.color = new_col
item.config.symbol_color = new_col
def add_new_curve(self, name: str = None, entry: str = None):
def add_new_curve(self, device: str = None, signal: str = None):
"""Add a new device-type CurveRow with an assigned colormap color.
Args:
name (str, optional): Device name.
entry (str, optional): Device entry.
style (str, optional): Pen style. Defaults to "solid".
width (int, optional): Pen width. Defaults to 4.
symbol_size (int, optional): Symbol size. Defaults to 7.
device (str, optional): Device name.
signal (str, optional): Device entry.
Returns:
CurveRow: The newly created top-level row.
@@ -630,7 +626,7 @@ class CurveTree(BECWidget, QWidget):
widget_class="Curve",
parent_id=self.waveform.gui_id,
source="device",
signal=DeviceSignal(name=name or "", entry=entry or ""),
signal=DeviceSignal(device=device or "", signal=signal or ""),
)
new_row = CurveRow(self.tree, parent_item=None, config=cfg, device_manager=self.dev)

View File

@@ -48,7 +48,10 @@ if TYPE_CHECKING: # pragma: no cover
else:
try:
import lmfit # type: ignore
except Exception: # pragma: no cover
except Exception as e: # pragma: no cover
logger.warning(
f"lmfit could not be imported: {e}. Custom DAP functionality will be unavailable."
)
lmfit = None
@@ -82,8 +85,8 @@ class Waveform(PlotBase):
"curves",
"x_mode",
"x_mode.setter",
"x_entry",
"x_entry.setter",
"signal_x",
"signal_x.setter",
"color_palette",
"color_palette.setter",
"skip_large_dataset_warning",
@@ -418,7 +421,7 @@ class Waveform(PlotBase):
self.scan_history_dialog.layout.addWidget(self.scan_history_widget)
self.scan_history_widget.scan_history_device_viewer.request_history_plot.connect(
lambda scan_id, device_name, signal_name: self.plot(
y_name=device_name, y_entry=signal_name, scan_id=scan_id
device_y=device_name, signal_y=signal_name, scan_id=scan_id
)
)
self.scan_history_dialog.finished.connect(self._scan_history_closed)
@@ -543,14 +546,14 @@ class Waveform(PlotBase):
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
@SafeProperty(str)
def x_entry(self) -> str | None:
def signal_x(self) -> str | None:
"""
The x signal name.
"""
return self.x_axis_mode["entry"]
@x_entry.setter
def x_entry(self, value: str | None):
@signal_x.setter
def signal_x(self, value: str | None):
"""
Set the x signal name.
@@ -560,7 +563,7 @@ class Waveform(PlotBase):
if value is None:
return
if self.x_axis_mode["name"] in ["auto", "index", "timestamp"]:
logger.warning("Cannot set x_entry when x_mode is not 'device'.")
logger.warning("Cannot set signal_x when x_mode is not 'device'.")
return
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(self.x_mode, value)
self._switch_x_axis_item(mode="device")
@@ -699,10 +702,10 @@ class Waveform(PlotBase):
arg1: list | np.ndarray | str | None = None,
y: list | np.ndarray | None = None,
x: list | np.ndarray | None = None,
x_name: str | None = None,
y_name: str | None = None,
x_entry: str | None = None,
y_entry: str | None = None,
device_x: str | None = None,
device_y: str | None = None,
signal_x: str | None = None,
signal_y: str | None = None,
color: str | None = None,
label: str | None = None,
dap: str | list[str] | None = None,
@@ -715,17 +718,17 @@ class Waveform(PlotBase):
Plot a curve to the plot widget.
Args:
arg1(list | np.ndarray | str | None): First argument, which can be x data, y data, or y_name.
arg1(list | np.ndarray | str | None): First argument, which can be x data, y data, or device_y.
y(list | np.ndarray): Custom y data to plot.
x(list | np.ndarray): Custom y data to plot.
x_name(str): Name of the x signal.
device_x(str): Name of the x signal.
- "auto": Use the best effort signal.
- "timestamp": Use the timestamp signal.
- "index": Use the index signal.
- Custom signal name of a device from BEC.
y_name(str): The name of the device for the y-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
device_y(str): The name of the device for the y-axis.
signal_x(str): The name of the entry for the x-axis.
signal_y(str): The name of the entry for the y-axis.
color(str): The color of the curve.
label(str): The label of the curve.
dap(str | list[str]): The dap model to use for the curve. When provided, a DAP curve is
@@ -758,7 +761,7 @@ class Waveform(PlotBase):
y_data = np.asarray(y)
if isinstance(arg1, str):
y_name = arg1
device_y = arg1
elif isinstance(arg1, list):
if isinstance(y, list):
source = "custom"
@@ -779,17 +782,17 @@ class Waveform(PlotBase):
x_data = arg1[:, 0]
y_data = arg1[:, 1]
# If y_name is set => device data
if y_name is not None and x_data is None and y_data is None:
# If device_y is set => device data
if device_y is not None and x_data is None and y_data is None:
source = "device"
# Validate or obtain entry
y_entry = self.entry_validator.validate_signal(name=y_name, entry=y_entry)
signal_y = self.entry_validator.validate_signal(device_y, signal_y)
# If user gave x_name => store in x_axis_mode, but do not set data here
if x_name is not None:
self.x_mode = x_name
if x_name not in ["timestamp", "index", "auto"]:
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(x_name, x_entry)
# If user gave device_x => store in x_axis_mode, but do not set data here
if device_x is not None:
self.x_mode = device_x
if device_x not in ["timestamp", "index", "auto"]:
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(device_x, signal_x)
# Decide label if not provided
if label is None:
@@ -798,7 +801,7 @@ class Waveform(PlotBase):
"Curve", [c.object_name for c in self.curves]
)
else:
label = f"{y_name}-{y_entry}"
label = f"{device_y}-{signal_y}"
# If color not provided, generate from palette
if color is None:
@@ -818,7 +821,7 @@ class Waveform(PlotBase):
# If it's device-based, attach DeviceSignal
if source == "device":
config.signal = DeviceSignal(name=y_name, entry=y_entry)
config.signal = DeviceSignal(device=device_y, signal=signal_y)
if scan_id is not None or scan_number is not None:
config.source = "history"
@@ -873,8 +876,8 @@ class Waveform(PlotBase):
f"Only device, history, or custom curves support fitting."
)
dev_name = getattr(getattr(device_curve.config, "signal", None), "name", None)
dev_entry = getattr(getattr(device_curve.config, "signal", None), "entry", None)
dev_name = getattr(getattr(device_curve.config, "signal", None), "device", None)
dev_entry = getattr(getattr(device_curve.config, "signal", None), "signal", None)
if dev_name is None:
dev_name = device_label
if dev_entry is None:
@@ -904,8 +907,8 @@ class Waveform(PlotBase):
# Attach device signal with DAP
config.signal = DeviceSignal(
name=dev_name,
entry=dev_entry,
device=dev_name,
signal=dev_entry,
dap=dap_name,
dap_oversample=dap_oversample,
dap_parameters=self._normalize_dap_parameters(dap_parameters, dap_name=dap_name),
@@ -953,7 +956,7 @@ class Waveform(PlotBase):
label = config.label
if config.source == "history":
label = f"{config.signal.name}-{config.signal.entry}-scan-{config.scan_number}"
label = f"{config.signal.device}-{config.signal.signal}-scan-{config.scan_number}"
config.label = label
if not label:
# Fallback label
@@ -1029,8 +1032,8 @@ class Waveform(PlotBase):
self, curve: Curve, scan_item: ScanDataContainer
) -> Curve | None:
# Check if the data are already set
device = curve.config.signal.name
entry = curve.config.signal.entry
device = curve.config.signal.device
entry = curve.config.signal.signal
all_devices_used = getattr(
getattr(scan_item, "_msg", None), "stored_data_info", None
@@ -1069,20 +1072,20 @@ class Waveform(PlotBase):
)
curve.setVisible(False)
return
x_entry_custom = self.x_axis_mode.get("entry")
if x_entry_custom is None:
x_entry_custom = self.entry_validator.validate_signal(
signal_x_custom = self.x_axis_mode.get("entry")
if signal_x_custom is None:
signal_x_custom = self.entry_validator.validate_signal(
self.x_axis_mode["name"], None
)
if x_entry_custom not in all_devices_used[self.x_axis_mode["name"]]:
if signal_x_custom not in all_devices_used[self.x_axis_mode["name"]]:
logger.warning(
f"Custom entry '{x_entry_custom}' for device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
f"Custom entry '{signal_x_custom}' for device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_shape = (
scan_item._msg.stored_data_info.get(self.x_axis_mode["name"])
.get(x_entry_custom)
.get(signal_x_custom)
.shape[0]
)
if x_shape != y_shape:
@@ -1092,9 +1095,9 @@ class Waveform(PlotBase):
curve.setVisible(False)
return
x_device = scan_item.devices.get(self.x_axis_mode["name"])
x_data = x_device.get(x_entry_custom).read().get("value")
x_data = x_device.get(signal_x_custom).read().get("value")
curve.config.current_x_mode = self.x_axis_mode["name"]
self._update_x_label_suffix(f" (custom: {self.x_axis_mode['name']}-{x_entry_custom})")
self._update_x_label_suffix(f" (custom: {self.x_axis_mode['name']}-{signal_x_custom})")
elif self.x_axis_mode["name"] == "auto":
if (
self._current_x_device is None
@@ -1109,24 +1112,24 @@ class Waveform(PlotBase):
curve.set_data(x=x_data, y=y_data)
self._update_x_label_suffix(" (auto: index)")
return curve
x_entry = self.entry_validator.validate_signal(scan_motors[0], None)
if x_entry not in all_devices_used.get(scan_motors[0], {}):
signal_x = self.entry_validator.validate_signal(scan_motors[0], None)
if signal_x not in all_devices_used.get(scan_motors[0], {}):
logger.warning(
f"Auto x entry '{x_entry}' for device '{scan_motors[0]}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
f"Auto x entry '{signal_x}' for device '{scan_motors[0]}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
if y_shape != all_devices_used.get(scan_motors[0]).get(x_entry, {}).shape[0]:
if y_shape != all_devices_used.get(scan_motors[0]).get(signal_x, {}).shape[0]:
logger.warning(
f"Shape mismatch for x data '{all_devices_used.get(scan_motors[0]).get(x_entry, {}).get('shape', [0])[0]}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
f"Shape mismatch for x data '{all_devices_used.get(scan_motors[0]).get(signal_x, {}).get('shape', [0])[0]}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
)
curve.setVisible(False)
return
x_data = scan_item.devices.get(scan_motors[0]).get(x_entry).read().get("value")
self._current_x_device = (scan_motors[0], x_entry)
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
x_data = scan_item.devices.get(scan_motors[0]).get(signal_x).read().get("value")
self._current_x_device = (scan_motors[0], signal_x)
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{signal_x})")
curve.config.current_x_mode = "auto"
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{signal_x})")
else: # Scan in auto mode was done and live scan already set the current x device
if self._current_x_device[0] not in all_devices_used:
logger.warning(
@@ -1472,8 +1475,8 @@ class Waveform(PlotBase):
return
data, access_key = self._fetch_scan_data_and_access()
for curve in self._sync_curves:
device_name = curve.config.signal.name
device_entry = curve.config.signal.entry
device_name = curve.config.signal.device
device_entry = curve.config.signal.signal
if access_key == "val":
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else:
@@ -1507,8 +1510,8 @@ class Waveform(PlotBase):
data, access_key = self._fetch_scan_data_and_access()
for curve in self._async_curves:
device_name = curve.config.signal.name
device_entry = curve.config.signal.entry
device_name = curve.config.signal.device
device_entry = curve.config.signal.signal
if access_key == "val": # live access
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
else: # history access
@@ -1561,8 +1564,8 @@ class Waveform(PlotBase):
bec_async_signals = self.client.device_manager.get_bec_signals(
["AsyncSignal", "AsyncMultiSignal"]
)
for entry_name, _, entry_data in bec_async_signals:
if entry_name == name and entry_data.get("obj_name") == signal:
for signal_name, _, entry_data in bec_async_signals:
if signal_name == name and entry_data.get("obj_name") == signal:
return True, entry_data.get("storage_name")
return False, signal
@@ -1573,8 +1576,8 @@ class Waveform(PlotBase):
Args:
curve(Curve): The curve to set up.
"""
name = curve.config.signal.name
signal = curve.config.signal.entry
name = curve.config.signal.device
signal = curve.config.signal.signal
async_signal_found, signal = self._check_async_signal_found(name, signal)
try:
@@ -1647,7 +1650,7 @@ class Waveform(PlotBase):
x_data = None # Reset x_data
y_data = None # Reset y_data
# Get the curve data
async_data = msg["signals"].get(curve.config.signal.entry, None)
async_data = msg["signals"].get(curve.config.signal.signal, None)
if async_data is None:
continue
# y-data
@@ -1691,12 +1694,12 @@ class Waveform(PlotBase):
# x_axis_mode is device signal
# Only consider device signals that are async for now, fallback is index
x_device_entry = self.x_axis_mode["entry"]
async_data = msg["signals"].get(x_device_entry, None)
signal_x = self.x_axis_mode["entry"]
async_data = msg["signals"].get(signal_x, None)
# Make sure the signal exists, otherwise fall back to index
if async_data is None:
# Try to grab the data from device signals
data_plot_x = self._get_x_data(plot_mode, x_device_entry)
data_plot_x = self._get_x_data(plot_mode, signal_x)
else:
data_plot_x = np.asarray(async_data["value"])
if x_data is not None:
@@ -1704,7 +1707,7 @@ class Waveform(PlotBase):
# Fallback incase data is not of equal length
if len(data_plot_x) != len(data_plot_y):
logger.warning(
f"Async data for curve {curve.name()} and x_axis {x_device_entry} is not of equal length. Falling back to 'index' plotting."
f"Async data for curve {curve.name()} and x_axis {signal_x} is not of equal length. Falling back to 'index' plotting."
)
data_plot_x = np.linspace(0, len(data_plot_y) - 1, len(data_plot_y))
@@ -1945,8 +1948,8 @@ class Waveform(PlotBase):
try:
fit_data = msg["data"][0]
curve.setData(np.asarray(fit_data["x"]), np.asarray(fit_data["y"]))
except Exception:
logger.exception(f"Failed to plot DAP result for curve '{curve.name()}'")
except Exception as e:
logger.exception(f"Failed to plot DAP result for curve '{curve.name()}', error: {e}")
return
metadata.update({"curve_id": curve_id})
@@ -1984,18 +1987,18 @@ class Waveform(PlotBase):
# 1 User wants custom signal
if self.x_axis_mode["name"] not in ["timestamp", "index", "auto"]:
x_name = self.x_axis_mode["name"]
x_entry = self.x_axis_mode.get("entry", None)
if x_entry is None:
x_entry = self.entry_validator.validate_signal(x_name, None)
device_x = self.x_axis_mode["name"]
signal_x = self.x_axis_mode.get("entry", None)
if signal_x is None:
signal_x = self.entry_validator.validate_signal(device_x, None)
# if the motor was not scanned, an empty list is returned and curves are not updated
if access_key == "val": # live data
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
x_data = data.get(device_x, {}).get(signal_x, {}).get(access_key, [0])
else: # history data
entry_obj = data.get(x_name, {}).get(x_entry)
entry_obj = data.get(device_x, {}).get(signal_x)
x_data = entry_obj.read()["value"] if entry_obj else [0]
new_suffix = f" (custom: {x_name}-{x_entry})"
self._current_x_device = (x_name, x_entry)
new_suffix = f" (custom: {device_x}-{signal_x})"
self._current_x_device = (device_x, signal_x)
# 2 User wants timestamp
if self.x_axis_mode["name"] == "timestamp":
@@ -2039,15 +2042,15 @@ class Waveform(PlotBase):
x_data = None
new_suffix = " (auto: index)"
else:
x_name = scan_report_devices[0]
x_entry = self.entry_validator.validate_signal(x_name, None)
device_x = scan_report_devices[0]
signal_x = self.entry_validator.validate_signal(device_x, None)
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
x_data = data.get(device_x, {}).get(signal_x, {}).get(access_key, None)
else:
entry_obj = data.get(x_name, {}).get(x_entry)
entry_obj = data.get(device_x, {}).get(signal_x)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})"
self._current_x_device = (x_name, x_entry)
new_suffix = f" (auto: {device_x}-{signal_x})"
self._current_x_device = (device_x, signal_x)
self._update_x_label_suffix(new_suffix)
return x_data
@@ -2125,7 +2128,7 @@ class Waveform(PlotBase):
for curve in self.curves:
if curve.config.source != "device":
continue
dev_name = curve.config.signal.name
dev_name = curve.config.signal.device
if dev_name in readout_priority_async:
self._async_curves.append(curve)
if hasattr(self.scan_item, "live_data"):
@@ -2512,12 +2515,12 @@ class DemoApp(QMainWindow): # pragma: no cover
dap_oversample=5,
)
# 3) lmfit-style dict: any subset of lmfit.Parameter fields.
# Here `center` is not fixed (vary=True) but its initial value is set.
# 3) Partial parameter override: this should still trigger guessing on the server
# because not all Gaussian parameters are explicitly specified.
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-override-dict",
label="custom-gaussian-partial-guess",
dap="GaussianModel",
dap_parameters={
"center": {"value": 1.2, "vary": True},
@@ -2525,7 +2528,7 @@ class DemoApp(QMainWindow): # pragma: no cover
},
)
# 4) Passing a real `lmfit.Parameters` object (optional: requires lmfit on the client).
# 4) Complete parameter override: this should skip guessing on the server.
if lmfit is not None:
params_gauss = lmfit.models.GaussianModel().make_params()
params_gauss["amplitude"].set(value=amplitude, vary=False)
@@ -2534,7 +2537,7 @@ class DemoApp(QMainWindow): # pragma: no cover
self.custom_waveform.plot(
x=x,
y=y,
label="custom-gaussian-fixed-params",
label="custom-gaussian-complete-no-guess",
dap="GaussianModel",
dap_parameters=params_gauss,
)
@@ -2554,10 +2557,11 @@ class DemoApp(QMainWindow): # pragma: no cover
+ rng_spec.normal(loc=0, scale=0.06, size=x_spec.size)
)
# 5) Composite model with partial overrides only: this should still trigger guessing.
self.custom_waveform.plot(
x=x_spec,
y=y_spec,
label="custom-gaussian-spectrum-fit",
label="custom-gaussian-spectrum-partial-guess",
dap=["GaussianModel", "GaussianModel", "GaussianModel"],
dap_parameters=[
{"center": {"value": centers[0], "vary": False}},
@@ -2566,6 +2570,31 @@ class DemoApp(QMainWindow): # pragma: no cover
],
)
# 6) Composite model with all component parameters specified: this should skip guessing.
self.custom_waveform.plot(
x=x_spec,
y=y_spec,
label="custom-gaussian-spectrum-complete-no-guess",
dap=["GaussianModel", "GaussianModel", "GaussianModel"],
dap_parameters=[
{
"amplitude": {"value": amplitudes[0], "vary": False},
"center": {"value": centers[0], "vary": False},
"sigma": {"value": sigmas[0], "vary": False, "min": 0.0},
},
{
"amplitude": {"value": amplitudes[1], "vary": False},
"center": {"value": centers[1], "vary": False},
"sigma": {"value": sigmas[1], "vary": False, "min": 0.0},
},
{
"amplitude": {"value": amplitudes[2], "vary": False},
"center": {"value": centers[2], "vary": False},
"sigma": {"value": sigmas[2], "vary": False, "min": 0.0},
},
],
)
def _populate_sine_curve_demo(self):
"""
Showcase how lmfit's base SineModel can struggle with a drifting baseline.
@@ -2590,18 +2619,6 @@ class DemoApp(QMainWindow): # pragma: no cover
label="custom-sine-composite",
dap=["SineModel", "LinearModel"],
dap_oversample=4,
# TODO have to guess correctly units for LMFit SineModel
# dap_parameters={
# "SineModel": {
# "amplitude": {"value": amplitude * 0.9, "vary": True},
# "frequency": {"value": 2 * np.pi * frequency * 1.05, "vary": True},
# "shift": {"value": 0.0, "vary": True},
# },
# "LinearModel": {
# "intercept": {"value": offset, "vary": True},
# "slope": {"value": slope, "vary": True},
# },
# },
)
if lmfit is None:
@@ -2610,7 +2627,8 @@ class DemoApp(QMainWindow): # pragma: no cover
return
def _log_sine_dap_params(self, params: dict, metadata: dict):
@staticmethod
def _log_sine_dap_params(params: dict, metadata: dict):
curve_id = metadata.get("curve_id")
if curve_id not in {
"custom-sine-data-SineModel",

View File

@@ -44,14 +44,12 @@ class RingCardWidget(QFrame):
self.setObjectName("RingCardWidget")
bg = self._get_theme_color("BORDER")
self.setStyleSheet(
f"""
self.setStyleSheet(f"""
#RingCardWidget {{
border: 1px solid {bg.name() if bg else '#CCCCCC'};
border-radius: 4px;
}}
"""
)
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import json
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
@@ -59,8 +61,10 @@ class BECQueue(BECWidget, CompactPopupWidget):
# Set up the table
self.table = QTableWidget(self)
# self.layout.addWidget(self.table)
self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status", "Cancel"])
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
["Scan Number", "Scan Name", "Type", "Status", "Cancel"]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
@@ -169,15 +173,23 @@ class BECQueue(BECWidget, CompactPopupWidget):
blocks = item.request_blocks
scan_types = []
scan_numbers = []
scan_names = []
scan_ids = []
user_metadatas = []
status = item.status
for request_block in blocks:
scan_type = request_block.msg.scan_type
user_metadata = request_block.msg.metadata.get("user_metadata", {})
scan_name = user_metadata.get("scan_name", scan_type)
if scan_type:
scan_types.append(scan_type)
scan_number = request_block.scan_number
if scan_number:
scan_numbers.append(str(scan_number))
if scan_name:
scan_names.append(scan_name)
if user_metadata:
user_metadatas.append(user_metadata)
scan_id = request_block.scan_id
if scan_id:
scan_ids.append(scan_id)
@@ -185,9 +197,18 @@ class BECQueue(BECWidget, CompactPopupWidget):
scan_types = ", ".join(scan_types)
if scan_numbers:
scan_numbers = ", ".join(scan_numbers)
if scan_names:
scan_names = ", ".join(scan_names)
# Pretty print user metadata as tooltip
tooltip = ""
if user_metadatas:
if len(user_metadatas) == 1:
tooltip = json.dumps(user_metadatas[0], indent=2)
else:
tooltip = json.dumps(user_metadatas, indent=2)
if scan_ids:
scan_ids = ", ".join(scan_ids)
self.set_row(index, scan_numbers, scan_types, status, scan_ids)
self.set_row(index, scan_numbers, scan_names, scan_types, status, scan_ids, tooltip)
busy = (
False
if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
@@ -196,13 +217,13 @@ class BECQueue(BECWidget, CompactPopupWidget):
self.set_global_state("warning" if busy else "default")
self.queue_busy.emit(busy)
def format_item(self, content: str, status=False) -> QTableWidgetItem:
def format_item(self, content: str, status=False, tooltip: str = "") -> QTableWidgetItem:
"""
Format the content of the table item.
Args:
content (str): The content to be formatted.
tooltip (str): Optional tooltip to display.
Returns:
QTableWidgetItem: The formatted item.
"""
@@ -210,7 +231,8 @@ class BECQueue(BECWidget, CompactPopupWidget):
content = ""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
# item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if tooltip:
item.setToolTip(tooltip)
if status:
try:
@@ -220,23 +242,36 @@ class BECQueue(BECWidget, CompactPopupWidget):
return item
return item
def set_row(self, index: int, scan_number: str, scan_type: str, status: str, scan_id: str):
def set_row(
self,
index: int,
scan_number: str,
scan_name: str,
scan_type: str,
status: str,
scan_id: str,
tooltip: str = "",
):
"""
Set the row of the table.
Args:
index (int): The index of the row.
scan_number (str): The scan number.
scan_name (str): The scan name.
scan_type (str): The scan type.
status (str): The status.
scan_id (str): The scan id.
tooltip (str): Optional tooltip to display (pretty-printed user metadata).
"""
abort_button = self._create_abort_button(scan_id)
abort_button.button.clicked.connect(self.delete_selected_row)
self.table.setItem(index, 0, self.format_item(scan_number))
self.table.setItem(index, 1, self.format_item(scan_type))
self.table.setItem(index, 2, self.format_item(status, status=True))
self.table.setCellWidget(index, 3, abort_button)
self.table.setItem(index, 0, self.format_item(scan_number, tooltip=tooltip))
self.table.setItem(index, 1, self.format_item(scan_name, tooltip=tooltip))
self.table.setItem(index, 2, self.format_item(scan_type, tooltip=tooltip))
self.table.setItem(index, 3, self.format_item(status, status=True, tooltip=tooltip))
self.table.setCellWidget(index, 4, abort_button)
def _create_abort_button(self, scan_id: str) -> AbortButton:
"""
@@ -254,14 +289,12 @@ class BECQueue(BECWidget, CompactPopupWidget):
abort_button.button.setIcon(
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
)
abort_button.setStyleSheet(
"""
abort_button.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
}
"""
)
""")
return abort_button
@@ -279,7 +312,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
"""
self.table.setRowCount(1)
self.set_row(0, "", "", "", "")
self.set_row(0, "", "", "", "", "")
if __name__ == "__main__": # pragma: no cover

View File

@@ -9,6 +9,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem
@@ -18,9 +19,10 @@ from bec_widgets.widgets.services.bec_status_box.status_item import StatusItem
if TYPE_CHECKING: # pragma: no cover
from bec_lib.client import BECClient
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
from bec_lib.messages import BECStatus, ServiceMetricMessage, StatusMessage
else:
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
@dataclass
@@ -200,7 +202,11 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
self.status_container[service_name].update({"info": service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
def update_service_status(
self,
services_info: dict[str, StatusMessage],
services_metric: dict[str, ServiceMetricMessage],
) -> None:
"""Callback function services_metric from BECServiceStatusMixin.
It updates the status of all services.
@@ -209,6 +215,9 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
"""
checked = [self.box_name]
# FIXME: We simply replace the pydantic message with dict for now until we refactor the widget
for val in services_info.values():
val.info = val.info.model_dump() if isinstance(val.info, BaseModel) else val.info
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)

View File

@@ -141,7 +141,11 @@ class StatusItem(QWidget):
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
metrics_text += f"<b>BEC_LIB VERSION:</b> {self.config.info['version']}<br>"
if "version" in self.config.info:
metrics_text += f"<b>BEC_LIB VERSION:</b> {self.config.info['version']}<br>"
if "versions" in self.config.info:
for component, version in self.config.info["versions"].items():
metrics_text += f"<b>{component.upper()} VERSION:</b> {version}<br>"
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":

View File

@@ -227,13 +227,11 @@ class IDEExplorer(BECWidget, QWidget):
try:
# Create the file with a basic template
with open(file_path, "w", encoding="utf-8") as f:
f.write(
f"""
f.write(f"""
\"\"\"
{filename} - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
\"\"\"
"""
)
""")
except Exception as e:
# Show error if file creation failed
@@ -281,8 +279,7 @@ class IDEExplorer(BECWidget, QWidget):
try:
# Create the file with a macro function template
with open(file_path, "w", encoding="utf-8") as f:
f.write(
f'''"""
f.write(f'''"""
{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
"""
@@ -296,8 +293,7 @@ def {function_name}():
print("Executing macro: {function_name}")
# TODO: Add your macro code here
pass
'''
)
''')
# Refresh the macro tree to show the new function
macro_dir_section.content_widget.refresh()

View File

@@ -4,6 +4,8 @@ from qtpy.QtCore import Property, QEasingCurve, QPointF, QPropertyAnimation, Qt,
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
class ToggleSwitch(QWidget):
"""
@@ -20,10 +22,10 @@ class ToggleSwitch(QWidget):
self.setFixedSize(40, 21)
self._thumb_pos = QPointF(3, 2) # Use QPointF for the thumb position
self._active_track_color = QColor(33, 150, 243)
self._active_thumb_color = QColor(255, 255, 255)
self._inactive_track_color = QColor(200, 200, 200)
self._inactive_thumb_color = QColor(255, 255, 255)
theme = getattr(QApplication.instance(), "theme", None)
if theme:
SafeConnect(self, theme.theme_changed, self._update_theme_colors)
self._update_theme_colors()
self._checked = checked
self._track_color = self.inactive_track_color
@@ -34,6 +36,16 @@ class ToggleSwitch(QWidget):
self._animation.setEasingCurve(QEasingCurve.Type.OutBack)
self.setProperty("checked", checked)
@SafeSlot(str)
def _update_theme_colors(self, _theme: str | None = None):
theme = getattr(QApplication.instance(), "theme", None)
colors = theme.colors if theme else {}
self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
@Property(bool)
def checked(self):
"""
@@ -155,7 +167,20 @@ class ToggleSwitch(QWidget):
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
window = ToggleSwitch()
apply_theme("dark")
widget = QWidget()
layout = QHBoxLayout(widget)
toggle = ToggleSwitch()
dark_mode_btn = DarkModeButton()
layout.addWidget(toggle)
layout.addWidget(dark_mode_btn)
window = QWidget()
window.setLayout(layout)
window.show()
sys.exit(app.exec())

View File

@@ -12,7 +12,7 @@ class DarkModeButton(BECWidget, QWidget):
ICON_NAME = "dark_mode"
PLUGIN = True
RPC = True
RPC = False
def __init__(
self,

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt, QTimer
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QPushButton,
@@ -16,6 +15,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets import SafeProperty
from bec_widgets.utils.widget_highlighter import WidgetHighlighter
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.plots.image.image import Image
@@ -49,22 +49,11 @@ class WidgetFinderComboBox(QComboBox):
self.refresh_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.refresh_button.clicked.connect(self.refresh_list)
# Purple Highlighter
self.highlighter = None
self.highlighter = WidgetHighlighter()
# refresh items - delay to fetch widgets after UI is ready in next event loop
QTimer.singleShot(0, self.refresh_list)
def _init_highlighter(self):
"""
Initialize the highlighter frame that will be used to highlight the inspected widget.
"""
self.highlighter = QFrame(self, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.highlighter.setAttribute(Qt.WA_TransparentForMouseEvents)
self.highlighter.setStyleSheet(
"border: 2px solid #FF00FF; border-radius: 6px; background: transparent;"
)
def resizeEvent(self, event):
super().resizeEvent(event)
btn_size = 16
@@ -110,33 +99,7 @@ class WidgetFinderComboBox(QComboBox):
target = self.currentData()
if not target:
return
# ensure highlighter exists, avoid calling methods on deleted C++ object
if not getattr(self, "highlighter", None):
self._init_highlighter()
else:
self.highlighter.hide()
# draw new
geom = target.frameGeometry()
pos = target.mapToGlobal(target.rect().topLeft())
self.highlighter.setGeometry(pos.x(), pos.y(), geom.width(), geom.height())
self.highlighter.show()
# Pulse and fade animation to draw attention
start_rect = QRect(pos.x() - 5, pos.y() - 5, geom.width() + 10, geom.height() + 10)
pulse = QPropertyAnimation(self.highlighter, b"geometry")
pulse.setDuration(300)
pulse.setStartValue(start_rect)
pulse.setEndValue(QRect(pos.x(), pos.y(), geom.width(), geom.height()))
fade = QPropertyAnimation(self.highlighter, b"windowOpacity")
fade.setDuration(2000)
fade.setStartValue(1.0)
fade.setEndValue(0.0)
fade.finished.connect(self.highlighter.hide)
group = QSequentialAnimationGroup(self)
group.addAnimation(pulse)
group.addAnimation(fade)
group.start()
self.highlighter.highlight(target)
@SafeProperty(str)
def widget_class_name(self) -> str:
@@ -167,9 +130,7 @@ class WidgetFinderComboBox(QComboBox):
Clean up the highlighter frame when the combobox is deleted.
"""
if self.highlighter:
self.highlighter.close()
self.highlighter.deleteLater()
self.highlighter = None
self.highlighter.cleanup()
def closeEvent(self, event):
"""

View File

@@ -0,0 +1,242 @@
from __future__ import annotations
import shiboken6
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QHeaderView,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import BECConnector
from bec_widgets.utils.widget_highlighter import WidgetHighlighter
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
class WidgetHierarchyDialog(QDialog):
"""Popup dialog listing all widgets currently alive in the QApplication."""
def __init__(self, root_widget: QWidget | None = None, parent: QWidget | None = None):
super().__init__(parent)
self.root_widget = root_widget
self.setWindowTitle("Widget Hierarchy")
self.resize(520, 640)
layout = QVBoxLayout(self)
controls = QHBoxLayout()
self._only_bec_checkbox = QCheckBox("Show only BECConnector widgets", self)
controls.addWidget(self._only_bec_checkbox)
self._visibility_filter = QComboBox(self)
self._visibility_filter.addItem("All widgets", "all")
self._visibility_filter.addItem("Visible only", "visible")
self._visibility_filter.addItem("Hidden only", "hidden")
controls.addWidget(self._visibility_filter)
self._refresh_button = QToolButton(self)
self._refresh_button.setText("Refresh")
self._refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._refresh_button.setAutoRaise(True)
self._refresh_button.setToolTip("Reload widget tree")
self._refresh_button.clicked.connect(self._refresh_tree)
controls.addWidget(self._refresh_button)
controls.addStretch()
layout.addLayout(controls)
self._tree = QTreeWidget(self)
self._tree.setAlternatingRowColors(True)
self._tree.setColumnCount(4)
self._tree.setHeaderLabels(["Widget", "GUI ID", "Visible", "Find"])
header = self._tree.header()
header.setStretchLastSection(False)
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
self._tree.setColumnWidth(0, 260)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self._tree.setColumnWidth(1, 160)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
self._tree.setColumnWidth(2, 80)
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
self._tree.setColumnWidth(3, 40)
header.setSectionsMovable(True)
layout.addWidget(self._tree)
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self._only_bec_checkbox.toggled.connect(self._refresh_tree)
self._visibility_filter.currentIndexChanged.connect(self._refresh_tree)
self._highlighter = WidgetHighlighter()
self._refresh_tree()
def refresh(self) -> None:
self._refresh_tree()
def closeEvent(self, event):
if self._highlighter is not None:
self._highlighter.cleanup()
super().closeEvent(event)
def _refresh_tree(self) -> None:
self._tree.clear()
only_bec = self._only_bec_checkbox.isChecked()
roots = self._collect_root_widgets()
widget_items: dict[QWidget, QTreeWidgetItem] = {}
seen: set[int] = set()
for root in roots:
for node in WidgetHierarchy.iter_widget_tree(root):
widget = node.widget
widget_id = id(widget)
if widget_id in seen:
continue
seen.add(widget_id)
if self._is_dialog_ancestor(widget):
continue
if only_bec and not isinstance(widget, BECConnector):
continue
parent_widget = (
WidgetHierarchy.get_becwidget_ancestor(widget) if only_bec else node.parent
)
parent_item = widget_items.get(parent_widget)
item = self._create_tree_item(widget)
if parent_item is None:
self._tree.addTopLevelItem(item)
else:
parent_item.addChild(item)
self._add_highlight_button(item, widget)
widget_items[widget] = item
self._tree.expandAll()
self._tree.resizeColumnToContents(0)
self._tree.resizeColumnToContents(1)
self._filter_tree_by_visibility()
def _collect_root_widgets(self) -> list[QWidget]:
if self.root_widget and shiboken6.isValid(self.root_widget):
return [self.root_widget]
app = QApplication.instance()
if app is None:
return []
roots: list[QWidget] = []
seen: set[int] = set()
for widget in app.allWidgets():
if not shiboken6.isValid(widget):
continue
parent = widget.parent()
if parent is not None and shiboken6.isValid(parent):
continue
key = id(widget)
if key in seen:
continue
seen.add(key)
roots.append(widget)
return roots
def _create_tree_item(self, widget: QWidget) -> QTreeWidgetItem:
labels = [
self._format_widget_label(widget),
self._get_gui_id(widget),
self._visible_label(widget),
"",
]
item = QTreeWidgetItem(labels)
item.setData(0, Qt.ItemDataRole.UserRole, widget)
item.setTextAlignment(2, Qt.AlignmentFlag.AlignCenter)
return item
@staticmethod
def _format_widget_label(widget: QWidget) -> str:
object_name = widget.objectName() or "<unnamed>"
return f"{widget.__class__.__name__} ({object_name})"
@staticmethod
def _get_gui_id(widget: QWidget) -> str:
gui_id = getattr(widget, "gui_id", None)
return str(gui_id) if gui_id else ""
@staticmethod
def _visible_label(widget: QWidget) -> str:
try:
return "Yes" if widget.isVisible() else "No"
except Exception as e:
logger.error(f"Error checking visibility for widget {widget}: {e}")
return "Unknown"
def _add_highlight_button(self, item: QTreeWidgetItem, widget: QWidget) -> None:
button = QToolButton(self._tree)
icon = material_icon("filter_center_focus", convert_to_pixmap=False)
button.setIcon(icon)
button.setEnabled(self._can_highlight(widget))
button.clicked.connect(lambda _, w=widget: self._highlight_widget(w))
self._tree.setItemWidget(item, 3, button)
def _highlight_widget(self, widget: QWidget | None) -> None:
if not self._can_highlight(widget):
return
self._highlighter.highlight(widget)
@staticmethod
def _can_highlight(widget: QWidget | None) -> bool:
if widget is None or not shiboken6.isValid(widget):
return False
try:
return widget.isVisible()
except Exception:
return False
def _filter_tree_by_visibility(self) -> None:
mode = self._visibility_filter.currentData()
if mode in (None, "all"):
return
for index in reversed(range(self._tree.topLevelItemCount())):
item = self._tree.topLevelItem(index)
if not self._filter_item_by_visibility(item, mode):
self._tree.takeTopLevelItem(index)
def _filter_item_by_visibility(self, item: QTreeWidgetItem, mode: str) -> bool:
has_match = self._matches_visibility_filter(item, mode)
for idx in reversed(range(item.childCount())):
child_item = item.child(idx)
if not self._filter_item_by_visibility(child_item, mode):
item.removeChild(child_item)
else:
has_match = True
return has_match
@staticmethod
def _matches_visibility_filter(item: QTreeWidgetItem, mode: str) -> bool:
if mode == "all":
return True
widget = item.data(0, Qt.ItemDataRole.UserRole)
if widget is None or not shiboken6.isValid(widget):
return False
try:
visible = widget.isVisible()
except Exception:
return False
if mode == "visible":
return visible
if mode == "hidden":
return not visible
return True
def _is_dialog_ancestor(self, widget: QWidget | None) -> bool:
current = widget
while current is not None and shiboken6.isValid(current):
if current is self:
return True
current = current.parentWidget()
return False

View File

@@ -55,7 +55,7 @@ bec_figure = BECFigure(gui_id="my_gui_app_id")
window.setCentralWidget(bec_figure)
# prepare to plot samx motor vs bpm4i value
bec_figure.plot(x_name="samx", y_name="bpm4i")
bec_figure.plot(device_x="samx", device_y="bpm4i")
```
In the example just above, the resulting application will show a plot of samx
@@ -96,7 +96,7 @@ window = QMainWindow()
bec_figure = BECFigure(parent=window, gui_id="my_gui_app_id")
window.setCentralWidget(bec_figure)
bec_figure.plot(x_name="samx", y_name="bpm4i")
bec_figure.plot(device_x="samx", device_y="bpm4i")
# ensuring proper cleanup
def final_cleanup():

View File

@@ -45,7 +45,7 @@ For the introduction given here, we will focus on the plotting widgets of BECWid
```python
plt = gui.new().new().new(gui.available_widgets.Waveform)
plt.plot(x_name='samx', y_name='bpm4i')
plt.plot(device_x='samx', device_y='bpm4i')
```
Here, we create a new plot with a subscription to the devices `samx` and `bpm4i` and assign the plot to the object `plt`. We can now use this object to further customize the plot, e.g. changing the title (`title`), axis labels (`x_label`)
<!-- or limits (`x_lim`). -->
@@ -112,7 +112,7 @@ Let's assume BEC was just started and the `gui` object is available in the clien
```python
dock_area = gui.new()
plt = dock_area.new().new(gui.available_widgets.Waveform)
plt.plot(x_name='samx', y_name='bpm4i')
plt.plot(device_x='samx', device_y='bpm4i')
plt.curves[0].set_color(color="white")
plt.title = '1D Waveform'
```

View File

@@ -47,9 +47,9 @@ heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
# Plot a heatmap with x and y motor positions and z detector signal
heatmap_widget.plot(
x_name='samx', # X-axis motor
y_name='samy', # Y-axis motor
z_name='bpm4i', # Z-axis detector signal
device_x='samx', # X-axis motor
device_y='samy', # Y-axis motor
device_z='bpm4i', # Z-axis detector signal
color_map='plasma'
)
heatmap_widget.title = "Grid Scan - Sample Position vs BPM Intensity"
@@ -66,12 +66,12 @@ heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
# Plot heatmap with specific data entries
heatmap_widget.plot(
x_name='motor1',
y_name='motor2',
z_name='detector1',
x_entry='RBV', # Use readback value for x
y_entry='RBV', # Use readback value for y
z_entry='value', # Use main value for z
device_x='motor1',
device_y='motor2',
device_z='detector1',
signal_x='RBV', # Use readback value for x
signal_y='RBV', # Use readback value for y
signal_z='value', # Use main value for z
color_map='viridis',
reload=True # Force reload of data
)

View File

@@ -32,7 +32,7 @@ dock_area = gui.new()
img_widget = dock_area.new().new(gui.available_widgets.Image)
# Add an ImageWidget to the BECFigure for a 2D detector
img_widget.image(device_name='eiger', device_entry='preview')
img_widget.image(device='eiger', signal='preview')
img_widget.title = "Camera Image - Eiger Detector"
```
@@ -46,7 +46,7 @@ dock_area = gui.new()
img_widget = dock_area.new().new(gui.available_widgets.Image)
# Add an ImageWidget to the BECFigure for a 2D detector
img_widget.image(device_name='waveform', device_entry='data')
img_widget.image(device='waveform', signal='data')
img_widget.title = "Line Detector Data"
# Optional: Set the color map and value range
@@ -84,7 +84,7 @@ The Image Widget can be configured for different detectors by specifying the cor
```python
# For a 2D camera detector
img_widget = fig.image(device_name='eiger', device_entry='preview')
img_widget = fig.image(device='eiger', signal='preview')
img_widget.set_title("Eiger Camera Image")
```
@@ -92,7 +92,7 @@ img_widget.set_title("Eiger Camera Image")
```python
# For a 1D line detector
img_widget = fig.image(device_name='waveform', device_entry='data')
img_widget = fig.image(device='waveform', signal='data')
img_widget.set_title("Line Detector Data")
```

View File

@@ -29,8 +29,8 @@ mm1 = dock_area.new().new(gui.available_widgets.MotorMap)
mm2 = dock_area.new().new(gui.available_widgets.MotorMap)
# Add signals to the MotorMaps
mm1.map(x_name='samx', y_name='samy')
mm2.map(x_name='aptrx', y_name='aptry')
mm1.map(device_x='samx', device_y='samy')
mm2.map(device_x='aptrx', device_y='aptry')
```
## Example 2 - Customizing Motor Map Display
@@ -57,7 +57,7 @@ You can dynamically change the motors being tracked and reset the history of the
mm1.reset_history()
# Change the motors being tracked
mm1.map(x_name='aptrx', y_name='aptry')
mm1.map(device_x='aptrx', device_y='aptry')
```
````

View File

@@ -20,7 +20,7 @@ The 2D scatter plot widget is designed for more complex data visualization. It e
```python
# Add a new dock_area, a new dock and a BECWaveForm to the dock
plt = gui.new().new().new(gui.available_widgets.ScatterWaveform)
plt.plot(x_name='samx', y_name='samy', z_name='bpm4i')
plt.plot(device_x='samx', device_y='samy', device_z='bpm4i')
```

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