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

Compare commits

..

295 Commits

Author SHA1 Message Date
semantic-release
846b6e6968 3.5.0
Automatically generated by python-semantic-release
2026-04-14 15:29:09 +00:00
f562c61e3c fix: connect signals the correct way around 2026-04-14 17:28:19 +02:00
bda5d38965 refactor: code cleanup 2026-04-14 17:28:19 +02:00
9b0ec9dd79 fix(bec_console): persistent bec session 2026-04-14 17:28:19 +02:00
1754e759f0 fix: create new bec shell if deleted 2026-04-14 17:28:19 +02:00
308e84d0e1 tests: update tests 2026-04-14 17:28:19 +02:00
fa2ef83bb9 fix: formatting in plugin template 2026-04-14 17:28:19 +02:00
02cb393bb0 feat: add qtermwidget plugin and replace web term 2026-04-14 17:28:19 +02:00
semantic-release
1d3e0214fd 3.4.4
Automatically generated by python-semantic-release
2026-04-14 07:33:15 +00:00
37747babda fix: check for duplicate subscriptions in GUIClient 2026-04-14 09:32:17 +02:00
32f5d486d3 fix: make gui client registry callback non static 2026-04-14 09:32:17 +02:00
0ff1fdc815 fix: remove staticmethod subscription 2026-04-14 09:32:17 +02:00
c7de320ca5 fix: check duplicate stream sub 2026-04-14 09:32:17 +02:00
semantic-release
5b23dce3d0 3.4.3
Automatically generated by python-semantic-release
2026-04-13 09:20:13 +00:00
5e84d3bec6 fix: Set OPHYD_CONTROL_LAYER to dummy for tests 2026-04-13 11:19:22 +02:00
semantic-release
9a2396ee9c 3.4.2
Automatically generated by python-semantic-release
2026-04-01 12:55:30 +00:00
2dab16b684 test: Add tests for admin access 2026-04-01 14:54:28 +02:00
e6c8cd0b1a fix: allow admin user to pass deployment group check 2026-04-01 14:54:28 +02:00
242f8933b2 fix(bec-atlas-admin-view): Fix atlas_url to bec-atlas-prod.psi.ch 2026-04-01 14:54:28 +02:00
semantic-release
83ac6bcd37 3.4.1
Automatically generated by python-semantic-release
2026-04-01 08:51:56 +00:00
90ecd8ea87 fix(ring): hook update hover to update method 2026-04-01 10:51:11 +02:00
copilot-swe-agent[bot]
6e5f6e7fbb test(ring_progress_bar): add unit tests for hover behavior 2026-04-01 10:51:11 +02:00
2f75aaea16 fix(ring): changed inheritance to BECWidget and added cleanup 2026-04-01 10:51:11 +02:00
677550931b fix(ring): minor general fixes 2026-04-01 10:51:11 +02:00
96b5179658 fix(ring_progress_bar): added hover mouse effect 2026-04-01 10:51:11 +02:00
e25b6604d1 fix(hover_widget): make it fancy + mouse tracking 2026-04-01 10:51:11 +02:00
semantic-release
f74c5a4516 3.4.0
Automatically generated by python-semantic-release
2026-03-26 11:25:40 +00:00
a2923752c2 fix(waveform): alignment panel indicators request autoscale if updated 2026-03-26 12:24:56 +01:00
a486c52058 feat(waveform): 1D alignment mode panel 2026-03-26 12:24:56 +01:00
31389a3dd0 fix(lmfit_dialog): compact layout size policy for better alignment panel UX 2026-03-26 12:24:56 +01:00
semantic-release
1676efc1ea 3.3.4
Automatically generated by python-semantic-release
2026-03-24 11:26:35 +00:00
copilot-swe-agent[bot]
05c38d9b82 fix(lmfit_dialog): fix fit_curve_id type annotation and remove_dap_data selection behavior
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bec-project/bec_widgets/sessions/97395c0e-0271-4cdf-b39f-f3117d21bfa3
2026-03-24 12:25:44 +01:00
f67b60ac98 fix(lmfit_dialog): dialog compact adjustment and cleanup of stale methods 2026-03-24 12:25:44 +01:00
5ec59d5dbb fix(lmfit_dialog): fix cpp object deleted 2026-03-24 12:25:44 +01:00
semantic-release
d46ffb59f0 3.3.3
Automatically generated by python-semantic-release
2026-03-23 18:24:36 +00:00
da400d20b6 fix(positioner_box): remove CompactPopupWidget inheritance 2026-03-23 19:23:47 +01:00
semantic-release
20f06d8659 3.3.2
Automatically generated by python-semantic-release
2026-03-22 19:13:32 +00:00
3d29a67c0b fix: typos 2026-03-22 20:12:46 +01:00
semantic-release
e7ef8a3891 3.3.1
Automatically generated by python-semantic-release
2026-03-20 14:01:06 +00:00
90222f3082 fix(dap_combobox): rewritten as proper combobox 2026-03-20 15:00:12 +01:00
79af15a88b fix(dap_combobox): added safeguard for no DAP models 2026-03-20 15:00:12 +01:00
semantic-release
ad01011a3e 3.3.0
Automatically generated by python-semantic-release
2026-03-20 13:18:49 +00:00
d4ecefd80a fix: Fix black 2026 formatting 2026-03-20 14:17:41 +01:00
d4afcb6832 refactor(fuzzy-search): unify is_match for fuzzy search 2026-03-20 14:17:41 +01:00
2b0f575733 refactor(atlas-http-service): Rename AtlasEndpoints 2026-03-20 14:17:41 +01:00
0c6f3f8352 fix(admin-widget): Cleanup and minor improvements 2026-03-20 14:17:41 +01:00
48c9c83bb0 fix(admin_view): minor changes
fixed the styling and avoid shadowing styles
minor rewording
2026-03-20 14:17:41 +01:00
ab223d5fdc refactor: fix formatting, running black 2026.1 2026-03-20 14:17:41 +01:00
137e572a94 fix(admin-view): generate RPC interface for AdminView 2026-03-20 14:17:41 +01:00
b14b046882 fix(main-app): fix id for main-app init of AdminView 2026-03-20 14:17:41 +01:00
a7a9458180 refactor: address review comments 2026-03-20 14:17:41 +01:00
23c146b3e6 fix(bec-atlas-admin-view): Fix connect_slot for dispatcher 2026-03-20 14:17:41 +01:00
df44d9b50e test(bec-atlas-admin-view): complement tests for BECAtlasAdminView, ExperimentSelection, BECAtlasHTTPService 2026-03-20 14:17:41 +01:00
de941d1bc5 fix(actions): allow minimum icon size for actions in toolbar 2026-03-20 14:17:41 +01:00
34e80ee8f9 test(bec-atlas-http-service): add tests for http service 2026-03-20 14:17:41 +01:00
d1a1d85abd fix(login-dialog): remove login_dialog 2026-03-20 14:17:41 +01:00
8e53ae2d39 fix(RPC): fix rpc access 2026-03-20 14:17:41 +01:00
889e9c0994 fix(pyproject): add PyJWT as dependency 2026-03-20 14:17:41 +01:00
f565deb71d fix(main-app): skip on_enter/exit hooks if darkmodebutton clicked 2026-03-20 14:17:41 +01:00
895b318990 refactor: cleanup widgets 2026-03-20 14:17:41 +01:00
3a17a249ed refactor(admin-view): Refactor experiment selection, http service, admin view, and add main view 2026-03-20 14:17:41 +01:00
598c453a18 feat(experiment-selection): add experiment selection widget 2026-03-20 14:17:41 +01:00
63059a4ef8 feat(admin-view): add admin view to views 2026-03-20 14:17:41 +01:00
ec58fbd6d8 feat(bec-atlas-admin-view): Add initial admin view 2026-03-20 14:17:41 +01:00
17708730fc feat(bec-atlas-admin-view): add http service through QNetworkAccessManager 2026-03-20 14:17:41 +01:00
1384a329ab feat(bec-atlas-admin-view): Add login dilaog 2026-03-20 14:17:41 +01:00
semantic-release
da1dc85b44 3.2.4
Automatically generated by python-semantic-release
2026-03-19 17:23:19 +00:00
28be696f7c fix(main_app): setApplicationName("BEC") 2026-03-19 18:22:29 +01:00
semantic-release
008c3a223a 3.2.3
Automatically generated by python-semantic-release
2026-03-16 15:07:09 +00:00
b9145d762c fix: check adding parent for filesystemmodel 2026-03-16 16:06:22 +01:00
37a5dc2e9e fix: refactor client mock with global fakeredis 2026-03-16 16:06:22 +01:00
1351fcd47b ci: fix path for uploading logs on failure 2026-03-16 15:49:22 +01:00
semantic-release
14a6b04b11 3.2.2
Automatically generated by python-semantic-release
2026-03-16 14:28:24 +00:00
4c9d7fddce fix(image): disconnecting of 2d monitor 2026-03-16 15:26:40 +01:00
semantic-release
39ecb89196 3.2.1
Automatically generated by python-semantic-release
2026-03-16 14:08:42 +00:00
974f25997d fix(e2e): bec shell excluded from e2e testing 2026-03-16 15:07:51 +01:00
e061fa31a9 fix(e2e): bec dock rpc fixed synchronization 2026-03-16 15:07:51 +01:00
718f99527c fix(e2e): timeout for maybe_remove_dock_area 2026-03-16 15:07:51 +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
d577fac02f test(scan control): avoid strict length comparisons 2025-12-23 13:14:28 +01:00
0349c87261 ci: use auth.token instead of login_or_token 2025-12-23 13:14:28 +01:00
274 changed files with 23024 additions and 8181 deletions

View File

@@ -62,4 +62,4 @@ runs:
uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e ./bec_widgets[dev,pyside6]
uv pip install --system -e ./bec_widgets[dev,qtermwidget]

View File

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

View File

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

View File

@@ -17,6 +17,10 @@ on:
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: write

View File

@@ -9,10 +9,10 @@ jobs:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
@@ -23,15 +23,16 @@ jobs:
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: '3.11'
auto-update-conda: true
auto-activate-base: true
python-version: "3.11"
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
sudo apt-get -y install ttyd
- name: Conda install and run pytest
run: |
@@ -54,5 +55,5 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
retention-days: 7
path: ./bec/logs/*.log
retention-days: 7

View File

@@ -2,7 +2,18 @@ name: Sync PR to Project
on:
pull_request:
types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize]
types:
[
opened,
assigned,
unassigned,
edited,
ready_for_review,
converted_to_draft,
reopened,
synchronize,
closed,
]
jobs:
sync-project:
@@ -13,28 +24,12 @@ jobs:
pull-requests: read
contents: read
env:
PROJECT_NUMBER: 3 # BEC Project
ORG: 'bec-project'
REPO: 'bec_widgets'
TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Set up python environment
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Checkout repo
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Install dependencies
run: |
pip install -r ./.github/scripts/pr_issue_sync/requirements.txt
- name: Sync PR to Project
run: |
python ./.github/scripts/pr_issue_sync/pr_issue_sync.py
uses: bec-project/action-issue-sync-pr@v1
with:
token: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
org: ${{ github.repository_owner }}
repo: ${{ github.event.repository.name }}
project-number: 3
pr-number: ${{ github.event.pull_request.number }}

4
.gitignore vendored
View File

@@ -177,4 +177,6 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.idea/
#
tombi.toml

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,43 @@
from __future__ import annotations
from typing import Literal
from bec_lib import bec_logger
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
logger = bec_logger.logger
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
return _dock_area
def dock_area(
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.
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:
BECDockArea: The created advanced dock area.
"""
widget = BECDockArea(
object_name=object_name,
root_widget=True,
profile_namespace="bec",
startup_profile=startup_profile,
)
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
return widget
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:

View File

@@ -27,10 +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.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
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
@@ -41,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):
@@ -74,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)
@@ -100,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 = (
@@ -116,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
@@ -132,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;
@@ -150,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):
"""
@@ -175,16 +183,25 @@ 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__(
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
self,
parent=None,
gui_id: str = None,
window_title="BEC Launcher",
launch_gui_class: str = None,
launch_gui_id: str = None,
*args,
**kwargs,
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
@@ -198,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)
@@ -211,11 +228,13 @@ class LaunchWindow(BECMainWindow):
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
main_label="BEC Advanced Dock Area",
description="Flexible application for managing modular widgets and user profiles.",
action_button=self._open_dock_area,
show_selector=True,
selector_items=list_profiles("bec"),
)
self._refresh_dock_area_profiles(preserve_selection=False)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
@@ -265,6 +284,11 @@ class LaunchWindow(BECMainWindow):
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
if launch_gui_class and launch_gui_id:
# If a specific gui class is provided, launch it and hide the launcher
self.launch(launch_gui_class, name=launch_gui_id)
self.hide()
def register_tile(
self,
name: str,
@@ -300,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:
@@ -326,6 +350,73 @@ class LaunchWindow(BECMainWindow):
self.tiles[name] = tile
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
"""
Refresh the dock-area profile selector, optionally preserving the selection.
Defaults to Start Empty when no valid selection can be preserved.
Args:
preserve_selection(bool): Whether to preserve the current selection or not.
"""
tile = self.tiles.get("dock_area")
if tile is None or tile.selector is None:
return
selector = tile.selector
selected_text = (
selector.currentText().strip() if preserve_selection and selector.count() > 0 else ""
)
profiles = list_profiles("bec")
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
selector.blockSignals(True)
selector.clear()
for profile in selector_items:
selector.addItem(profile)
if selected_text:
# Try to preserve the current selection
idx = selector.findText(selected_text, Qt.MatchFlag.MatchExactly)
if idx >= 0:
selector.setCurrentIndex(idx)
else:
# Selection no longer exists, fall back to default startup selection.
self._set_selector_to_default_profile(selector, profiles)
else:
# 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 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:
idx = selector.findText(last_profile, Qt.MatchFlag.MatchExactly)
if idx >= 0:
selector.setCurrentIndex(idx)
return
# If nothing else, select first item
if selector.count() > 0:
selector.setCurrentIndex(0)
def launch(
self,
launch_script: str,
@@ -347,14 +438,14 @@ class LaunchWindow(BECMainWindow):
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
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:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
WidgetContainerUtils.raise_for_invalid_name(name)
# If name already exists, generate a unique one with counter suffix
if name in existing_dock_areas:
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
@@ -372,32 +463,31 @@ 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:
raise ValueError(f"Launch script {launch_script} not found.")
result_widget = launch(name)
result_widget.resize(result_widget.minimumSizeHint())
result_widget = launch(name, **kwargs)
# TODO Should we simply use the specified name as title here?
result_widget.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
if geometry is not None:
result_widget.setGeometry(*geometry)
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
@@ -432,13 +522,15 @@ class LaunchWindow(BECMainWindow):
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
QApplication.processEvents()
window.setWindowTitle(f"BEC - {filename}")
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()
@@ -448,12 +540,14 @@ class LaunchWindow(BECMainWindow):
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
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)
@@ -462,11 +556,11 @@ class LaunchWindow(BECMainWindow):
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
apply_window_geometry(window, geometry)
window.show()
return window
@@ -491,6 +585,21 @@ class LaunchWindow(BECMainWindow):
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_dock_area(self):
"""
Open Advanced Dock Area using the selected profile.
"""
tile = self.tiles.get("dock_area")
if tile is None or tile.selector is None:
startup_profile = None
else:
selection = tile.selector.currentText().strip()
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):
"""
Open a widget from the available widgets.
@@ -502,6 +611,10 @@ class LaunchWindow(BECMainWindow):
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
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):
"""
@@ -538,6 +651,7 @@ class LaunchWindow(BECMainWindow):
self.hide()
def showEvent(self, event):
self._refresh_dock_area_profiles()
super().showEvent(event)
self.setFixedSize(self.size())
@@ -546,10 +660,19 @@ class LaunchWindow(BECMainWindow):
Check if the launcher is the last widget in the application.
"""
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 4
# get all parents of connections
for connection in connections.values():
try:
parent = connection.parent()
if parent is None and connection.objectName() != self.objectName():
logger.info(
f"Found non-launcher connection without parent: {connection.objectName()}"
)
return False
except Exception as e:
logger.error(f"Error getting parent of connection: {e}")
return False
return True
def _turn_off_the_lights(self, connections: dict):
"""
@@ -581,10 +704,13 @@ class LaunchWindow(BECMainWindow):
self.hide()
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
apply_theme("dark")
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

View File

@@ -1,17 +1,29 @@
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
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.admin_view.admin_view import AdminView
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,53 +55,58 @@ 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="main_workspace", auto_profile_namespace=False
)
self.ads.setObjectName("MainWorkspace")
self.dock_area = DockAreaView(self)
self.device_manager = DeviceManagerView(self)
self.developer_view = DeveloperView(self)
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
self.admin_view = AdminView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
)
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",
)
# 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,
# )
self.add_view(
icon="code_blocks",
title="IDE",
widget=self.developer_view,
id="developer_view",
exclusive=True,
icon="admin_panel_settings",
title="Admin View",
widget=self.admin_view,
mini_text="Admin",
from_top=False,
)
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",
)
@@ -97,6 +114,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)
@@ -112,7 +132,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,
@@ -126,7 +146,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.
@@ -139,10 +160,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,
@@ -152,13 +174,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:
@@ -167,6 +191,12 @@ class BECMainApp(BECMainWindow):
# Internal: route sidebar selection to the stack
def _on_view_selected(self, vid: str) -> None:
# Special handling for views that can not be switched to (e.g. dark mode toggle)
# Not registered as proper view with a stack index, so we ignore any logic below
# as it will anyways not result in a stack switch.
idx = self._view_index.get(vid)
if idx is None or not (0 <= idx < self.stack.count()):
return
# Determine current view
current_index = self.stack.currentIndex()
current_view = (
@@ -192,8 +222,167 @@ 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 = []
if __name__ == "__main__": # pragma: no cover
# --- 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 macros.",
)
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
"""
Main function to run the BEC main application, exposed as a script entry point through
pyproject.toml.
"""
# pylint: disable=import-outside-toplevel
import argparse
import sys
@@ -205,23 +394,21 @@ if __name__ == "__main__": # pragma: no cover
args, qt_args = parser.parse_known_args(sys.argv[1:])
app = QApplication([sys.argv[0], *qt_args])
app.setApplicationName("BEC")
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))
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())
# 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)
w.show()
sys.exit(app.exec())
if __name__ == "__main__": # pragma: no cover
main()

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

@@ -0,0 +1,35 @@
"""Module for Admin View."""
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
class AdminView(ViewBase):
"""
A view for administrators to change the current active experiment, manage messaging
services, and more tasks reserved for users with admin privileges.
"""
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)
self.admin_widget = BECAtlasAdminView(parent=self)
self.set_content(self.admin_widget)
@SafeSlot()
def on_exit(self) -> None:
"""Called before the view is hidden.
Default implementation does nothing. Override in subclasses.
"""
self.admin_widget.logout()

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

@@ -1,3 +1,5 @@
from __future__ import annotations
import re
import markdown
@@ -11,11 +13,12 @@ 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.bec_console.bec_console import BecConsole, BECShell
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@@ -76,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)
@@ -88,14 +93,15 @@ class DeveloperWidget(DockAreaWidget):
# Initialize the widgets
self.explorer = IDEExplorer(self)
self.explorer.setObjectName("Explorer")
self.console = WebConsole(self)
self.console.setObjectName("Console")
self.terminal = WebConsole(self, startup_cmd="")
self.console = BECShell(self, rpc_exposed=False)
self.console.setObjectName("BEC Shell")
self.terminal = BecConsole(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",
@@ -124,6 +130,7 @@ class DeveloperWidget(DockAreaWidget):
# Connect editor signals
self.explorer.file_open_requested.connect(self._open_new_file)
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
self.monaco.focused_editor.connect(self._on_focused_editor_changed)
self.toolbar.show_bundles(["save", "execution", "settings"])
@@ -280,14 +287,17 @@ class DeveloperWidget(DockAreaWidget):
@SafeSlot()
def on_save(self):
"""Save the currently focused file in the Monaco editor."""
self.monaco.save_file()
@SafeSlot()
def on_save_as(self):
"""Save the currently focused file in the Monaco editor with a 'Save As' dialog."""
self.monaco.save_file(force_save_as=True)
@SafeSlot()
def on_vim_triggered(self):
"""Toggle Vim mode in the Monaco editor."""
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
@SafeSlot(bool)
@@ -304,22 +314,38 @@ class DeveloperWidget(DockAreaWidget):
widget = self.script_editor_tab.widget()
if not isinstance(widget, MonacoWidget):
return
if widget.modified:
# Save the file before execution if there are unsaved changes
self.monaco.save_file()
if widget.modified:
# If still modified, user likely cancelled save dialog
return
self.current_script_id = upload_script(self.client.connector, widget.get_text())
self.console.write(f'bec._run_script("{self.current_script_id}")')
print(f"Uploaded script with ID: {self.current_script_id}")
@SafeSlot()
def on_stop(self):
"""Stop the execution of the currently running script"""
if not self.current_script_id:
return
self.console.send_ctrl_c()
@property
def current_script_id(self):
"""Get the ID of the currently running script."""
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value: str | None):
"""
Set the ID of the currently running script.
Args:
value (str | None): The script ID to set.
Raises:
ValueError: If the provided value is not a string or None.
"""
if value is not None and not isinstance(value, str):
raise ValueError("Script ID must be a string.")
old_script_id = self._current_script_id
@@ -336,6 +362,28 @@ class DeveloperWidget(DockAreaWidget):
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
)
@SafeSlot(CDockWidget)
def _on_focused_editor_changed(self, tab_widget: CDockWidget):
"""
Disable the run / stop buttons if the focused editor is a macro file.
Args:
tab_widget: The currently focused tab widget in the Monaco editor.
"""
if not isinstance(tab_widget, CDockWidget):
return
widget = tab_widget.widget()
if not isinstance(widget, MonacoWidget):
return
file_scope = widget.metadata.get("scope", "")
run_action = self.toolbar.components.get_action("run")
stop_action = self.toolbar.components.get_action("stop")
if "macro" in file_scope:
run_action.action.setEnabled(False)
stop_action.action.setEnabled(False)
else:
run_action.action.setEnabled(True)
stop_action.action.setEnabled(True)
@SafeSlot(dict, dict)
def on_script_execution_info(self, content: dict, metadata: dict):
"""
@@ -359,25 +407,6 @@ class DeveloperWidget(DockAreaWidget):
widget.set_highlighted_lines(line_number, line_number)
def cleanup(self):
"""Clean up resources used by the developer widget."""
self.delete_all()
return super().cleanup()
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
from bec_widgets.applications.main_app import BECMainApp
app = QApplication(sys.argv)
apply_theme("dark")
_app = BECMainApp()
_app.show()
# developer_view.show()
# developer_view.setWindowTitle("Developer View")
# developer_view.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -1,5 +1,7 @@
"""Dialogs for device configuration forms and ophyd testing."""
from typing import Any, Iterable, Tuple
from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.logger import bec_logger
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES
@@ -20,6 +22,7 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo
)
DEFAULT_DEVICE = "CustomDevice"
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
logger = bec_logger.logger
@@ -28,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
@@ -47,11 +50,11 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
self.text_box.setReadOnly(True)
layout.addWidget(self.text_box)
# Connect signal for validation messages
# Load and apply configuration
config = config or {}
self.device_manager_ophyd_test.change_device_configs([config], True, True)
device_name = config.get("name", None)
if device_name:
self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name)
# Dialog Buttons: equal size, stacked horizontally
button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close)
@@ -66,6 +69,9 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
self._resize_dialog()
self.finished.connect(self._finished)
# Add and test device config
self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True)
def _resize_dialog(self):
"""Resize the dialog based on the screen size."""
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
@@ -127,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 = ""
@@ -170,12 +176,17 @@ class DeviceFormDialog(QtWidgets.QDialog):
self.cancel_btn = QtWidgets.QPushButton("Cancel")
self.reset_btn = QtWidgets.QPushButton("Reset Form")
btn_layout = QtWidgets.QHBoxLayout()
for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn):
btn_box = QtWidgets.QDialogButtonBox(self)
btn_box.addButton(self.cancel_btn, QtWidgets.QDialogButtonBox.ButtonRole.RejectRole)
btn_box.addButton(self.reset_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
btn_box.addButton(
self.test_connection_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole
)
btn_box.addButton(self.add_btn, QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole)
for btn in btn_box.buttons():
btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
btn_layout.addWidget(btn)
btn_box = QtWidgets.QGroupBox("Actions")
btn_box.setLayout(btn_layout)
layout.addWidget(btn_box)
frame_layout.addWidget(btn_box)
# Connect signals to explicit slots
@@ -188,11 +199,17 @@ class DeviceFormDialog(QtWidgets.QDialog):
self.update_variant_combo(self._control_widgets["group_combo"].currentText())
self.finished.connect(self._finished)
# Wait dialog when adding config
self._wait_dialog: QtWidgets.QProgressDialog | None = None
@SafeSlot(int)
def _finished(self, state: int):
for widget in self._control_widgets.values():
widget.close()
widget.deleteLater()
if self._wait_dialog is not None:
self._wait_dialog.close()
self._wait_dialog.deleteLater()
@property
def config_validation_result(self) -> tuple[dict, int, int, str]:
@@ -268,42 +285,132 @@ class DeviceFormDialog(QtWidgets.QDialog):
OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE]
)
def _add_config(self):
config = self._device_config_template.get_config_fields()
config_status = ConfigStatus.UNKNOWN.value
connection_status = ConnectionStatus.UNKNOWN.value
validation_msg = ""
def _create_validation_dialog(self) -> QtWidgets.QProgressDialog:
"""
Create and show a validation progress dialog while validating the device configuration.
The dialog will be modal and prevent user interaction until validation is complete.
"""
wait_dialog = QtWidgets.QProgressDialog(
"Validating config... please wait", None, 0, 0, parent=self
)
wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
wait_dialog.setCancelButton(None)
wait_dialog.setMinimumDuration(0)
return wait_dialog
def _create_and_run_ophyd_validation(self, config: dict[str, Any]) -> OphydValidation:
"""Run ophyd validation test on the current device configuration."""
ophyd_validation = OphydValidation(parent=self)
ophyd_validation.validation_completed.connect(self._handle_validation_result)
ophyd_validation.multiple_validations_completed.connect(
self._handle_devices_already_in_session_results
)
# NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled
# tasks in the event loop are processed. This avoids potential deadlocks. In particular,
# this is relevant for the _wait_dialog exec which opens a modal dialog during validation
# and therefore must not have the signal emitted immediately in the same event loop iteration.
# Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock.
QtCore.QTimer.singleShot(
0, lambda: ophyd_validation.change_device_configs([config], True, False)
)
return ophyd_validation
@SafeSlot(list)
def _handle_devices_already_in_session_results(
self, validation_results: _ValidationResultIter
) -> None:
"""Handle completion if device is already in session."""
if len(validation_results) != 1:
logger.error(
"Expected a single device validation result, but got multiple. Using first result."
)
result = validation_results[0] if len(validation_results) > 0 else None
if result is None:
logger.error(
f"Received validation results: {validation_results} of unexpected length 0. Returning."
)
return
device_config, config_status, connection_status, validation_msg = result
self._handle_validation_result(
device_config, config_status, connection_status, validation_msg
)
@SafeSlot(dict, int, int, str)
def _handle_validation_result(
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
):
"""Handle completion of validation."""
try:
if DeviceModel.model_validate(config) == DeviceModel.model_validate(
self._validation_result[0]
if (
DeviceModel.model_validate(device_config)
== DeviceModel.model_validate(self._validation_result[0])
and connection_status == ConnectionStatus.UNKNOWN.value
):
config_status = self._validation_result[1]
# Config unchanged, we can reuse previous connection status. Only do this if the new
# connection status is UNKNOWN as the current validation should not test the connection.
connection_status = self._validation_result[2]
validation_msg = self._validation_result[3]
except Exception:
logger.debug(
f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses."
f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation."
)
self._validation_result = (device_config, config_status, connection_status, validation_msg)
if self._wait_dialog is not None:
self._wait_dialog.accept()
self._wait_dialog.close()
self._wait_dialog.deleteLater()
self._wait_dialog = None
def _add_config(self):
"""
Adding a config will always run a validation check of the config without a connection test.
We will check if tests have already run, and reuse the information in case they also tested the connection to the device.
"""
config = self._device_config_template.get_config_fields()
# I. First we validate that the device name is valid, as this may create issues within the OphydValidation widget.
# Validate device name first. If invalid, this should immediately block adding the device.
if not validate_name(config.get("name", "")):
msg_box = self._create_warning_message_box(
"Invalid Device Name",
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ",
)
msg_box.exec()
return
if config_status == ConfigStatus.INVALID.value:
msg_box = self._create_warning_message_box(
"Invalid Device Configuration",
f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{validation_msg}",
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}",
)
msg_box.exec()
return
self.accepted_data.emit(
config, config_status, connection_status, validation_msg, self._old_device_name
)
self.accept()
# II. Next we will run the validation check of the config without connection test.
# We will show a wait dialog while this is happening, and compare the results with the last known validation results.
# If the config is unchanged, we will use the connection status results from the last validation.
self._wait_dialog = self._create_validation_dialog()
ophyd_validation: OphydValidation | None = None
try:
ophyd_validation = self._create_and_run_ophyd_validation(config)
# NOTE If dialog was already closed, this means that a validation callback was already received
# which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above,
# this should not happen, but we keep the check for safety.
if self._wait_dialog is not None:
self._wait_dialog.exec() # This will block until the validation is complete
config, config_status, connection_status, validation_msg = self._validation_result
if config_status == ConfigStatus.INVALID.value:
msg_box = self._create_warning_message_box(
"Invalid Device Configuration",
f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{self._validation_result[3]}",
)
msg_box.exec()
return
self.accepted_data.emit(
config, config_status, connection_status, validation_msg, self._old_device_name
)
self.accept()
finally:
if ophyd_validation is not None:
ophyd_validation.close()
ophyd_validation.deleteLater()
def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox:
msg_box = QtWidgets.QMessageBox(self)
@@ -318,7 +425,6 @@ class DeviceFormDialog(QtWidgets.QDialog):
result = dialog.exec()
if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected):
self.config_validation_result = dialog.validation_result
# self._device_config_template.set_config_fields(self.config_validation_result[0])
def _reset_config(self):
self._device_config_template.reset_to_defaults()

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from enum import IntEnum
from functools import partial
from typing import TYPE_CHECKING, Dict, 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
@@ -12,16 +12,17 @@ from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components import OphydValidation
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
get_validation_icons,
)
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
if TYPE_CHECKING:
from bec_widgets.utils.colors import AccentColor
from bec_widgets.widgets.control.device_manager.components.device_table.device_table import (
_ValidationResultIter,
)
logger = bec_logger.logger
@@ -234,22 +235,18 @@ class UploadRedisDialog(QtWidgets.QDialog):
class UploadAction(IntEnum):
"""Enum for upload actions."""
CANCEL = QtWidgets.QDialog.Rejected
OK = QtWidgets.QDialog.Accepted
CANCEL = QtWidgets.QDialog.DialogCode.Rejected
OK = QtWidgets.QDialog.DialogCode.Accepted
CONNECTION_TEST_REQUESTED = 999
# Signal to trigger upload after confirmation
upload_confirmed = QtCore.Signal(int)
# Request ophyd validation for all untested device connections
# list of device configs, added: bool, connect: bool
request_ophyd_validation = QtCore.Signal(list, bool, bool)
def __init__(
self,
parent,
ophyd_test_widget: OphydValidation,
device_configs: dict[str, Tuple[dict, int, int]] | None = None,
):
def __init__(self, parent, device_configs: dict[str, Tuple[dict, int, int]] | None = None):
super().__init__(parent=parent)
self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {}
self.ophyd_test_widget = ophyd_test_widget
self._transparent_button_style = "background-color: transparent; border: none;"
self.colors = get_accent_colors()
@@ -267,14 +264,9 @@ class UploadRedisDialog(QtWidgets.QDialog):
self.has_invalid_configs: int = 0
self.has_untested_connections: int = 0
self.has_cannot_connect: int = 0
self._current_progress: int | None = None
self._setup_ui()
self._update_ui()
# Disable validation features if no ophyd test widget provided, else connect validation
self._validation_connection = self.ophyd_test_widget.validation_completed.connect(
self._update_from_ophyd_device_tests
)
def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]):
"""
@@ -288,18 +280,6 @@ class UploadRedisDialog(QtWidgets.QDialog):
self.device_configs = device_configs
self._update_ui()
def accept(self):
self.cleanup()
return super().accept()
def reject(self):
self.cleanup()
return super().reject()
def cleanup(self):
"""Cleanup on dialog finish."""
self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection)
def _setup_ui(self):
"""Setup the main UI for the dialog."""
self.setWindowTitle("Upload Configuration to BEC Server")
@@ -347,11 +327,6 @@ class UploadRedisDialog(QtWidgets.QDialog):
button_layout.addWidget(self.validate_connections_btn)
button_layout.addStretch()
button_layout.addSpacing(16)
# Progress bar
self._progress_bar = BECProgressBar(self)
self._progress_bar.setVisible(False)
button_layout.addWidget(self._progress_bar)
action_layout.addLayout(button_layout)
# Status indicator
@@ -498,7 +473,7 @@ class UploadRedisDialog(QtWidgets.QDialog):
@SafeSlot()
def _validate_connections(self):
"""Request validation of all untested connections."""
"""Request validation of all untested connections. This will close the dialog."""
testable_devices: List[dict] = []
for _, (config, _, connection_status) in self.device_configs.items():
if connection_status == ConnectionStatus.UNKNOWN.value:
@@ -507,13 +482,8 @@ class UploadRedisDialog(QtWidgets.QDialog):
testable_devices.append(config)
if len(testable_devices) > 0:
self.validate_connections_btn.setEnabled(False)
self._progress_bar.setVisible(True)
self._progress_bar.maximum = len(testable_devices)
self._progress_bar.minimum = 0
self._progress_bar.set_value(0)
self._current_progress = 0
self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True)
self.request_ophyd_validation.emit(testable_devices, True, True)
self.done(self.UploadAction.CONNECTION_TEST_REQUESTED)
@SafeSlot()
def _handle_upload(self):
@@ -543,7 +513,8 @@ class UploadRedisDialog(QtWidgets.QDialog):
[
detailed_text,
"These devices may not be reachable and disabled BEC upon loading the config.",
"Consider validating these connections before.",
"Consider validating these connections before proceeding.\n\n",
"Continue anyway?",
]
)
reply = QtWidgets.QMessageBox.critical(
@@ -611,35 +582,40 @@ class UploadRedisDialog(QtWidgets.QDialog):
return
self.update_device_status(device_config, config_status, connection_status)
@SafeSlot(list)
def _multiple_updates_from_ophyd_device_tests(self, validation_results: _ValidationResultIter):
"""
Callback slot for receiving multiple validation result updates from the ophyd test widget.
Args:
validation_results (list): List of tuples containing (device_config, config_status, connection_status, validation_msg).
"""
for cfg, cfg_status, conn_status, val_msg in validation_results:
self.update_device_status(cfg, cfg_status, conn_status)
self._update_ui()
@SafeSlot(dict, int, int)
def update_device_status(self, device_config: dict, config_status: int, connection_status: int):
"""Update the status of a specific device."""
# Update device config status
self._update_device_configs(device_config, config_status, connection_status, "")
# Recalculate summaries and UI state
self._update_ui()
def _update_device_configs(
self,
device_config: dict[str, Any],
config_status: int,
connection_status: int,
validation_msg: str,
):
device_name = device_config.get("name", "")
old_config, _, _ = self.device_configs.get(device_name, (None, None, None))
if old_config is not None:
self.device_configs[device_name] = (device_config, config_status, connection_status)
if self._current_progress is not None:
self._current_progress += 1
self._progress_bar.set_value(self._current_progress)
if self._current_progress >= self._progress_bar.maximum:
self._progress_bar.setVisible(False)
self._progress_bar.set_value(0)
self._current_progress = None
self.validation_completed()
self._update_ui()
return
# Update UI sections
self.config_section.add_device(device_config, config_status, connection_status)
# Recalculate summaries and UI state
self._update_ui()
def validation_completed(self):
"""Called when connection validation is completed."""
self.validate_connections_btn.setEnabled(True)
self._update_ui()
else:
# If device not found, add it
self.config_section.add_device(device_config, config_status, connection_status)
def main(): # pragma: no cover
@@ -705,12 +681,7 @@ def main(): # pragma: no cover
]
configs = {cfg[0]["name"]: cfg for cfg in sample_configs}
apply_theme("dark")
from unittest import mock
ophyd_test_widget = mock.MagicMock(spec=OphydValidation)
dialog = UploadRedisDialog(
parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget
)
dialog = UploadRedisDialog(parent=None, device_configs=configs)
dialog.show()
sys.exit(app.exec_())

View File

@@ -2,18 +2,29 @@ from __future__ import annotations
import os
from functools import partial
from typing import List, Literal, get_args
from typing import TYPE_CHECKING, List, Literal, get_args
import yaml
from bec_lib import config_helper
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.callback_handler import EventType
from bec_lib.endpoints import MessageEndpoints
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from bec_lib.messages import ConfigAction, ScanStatusMessage
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from qtpy.QtCore import QMetaObject, QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget
from bec_qthemes import apply_theme, material_icon
from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
ConfigChoiceDialog,
@@ -22,11 +33,12 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs i
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
UploadRedisDialog,
)
from bec_widgets.utils.colors import get_accent_colors
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,
@@ -35,11 +47,19 @@ from bec_widgets.widgets.control.device_manager.components import (
)
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
ConfigStatus,
ConnectionStatus,
)
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
DeviceInitializationProgressBar,
)
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_lib.client import BECClient
logger = bec_logger.logger
@@ -50,6 +70,84 @@ _yes_no_question = partial(
)
class CustomBusyWidget(QWidget):
"""Custom busy widget to show during device config upload."""
cancel_requested = Signal()
def __init__(self, parent=None, client: BECClient | None = None):
super().__init__(parent=parent)
# Widgets
self.progress = QWidget(parent=self)
self.progress_layout = QVBoxLayout(self.progress)
self.progress_layout.setContentsMargins(6, 6, 6, 6)
self.progress_inner = DeviceInitializationProgressBar(parent=self.progress, client=client)
self.progress_layout.addWidget(self.progress_inner)
self.progress.setMinimumWidth(320)
# Spinner
self.spinner = SpinnerWidget(parent=self)
scale = self._ui_scale()
spinner_size = int(scale * 0.12) if scale else 1
spinner_size = max(32, min(spinner_size, 96))
self.spinner.setFixedSize(spinner_size, spinner_size)
# Cancel button
self.cancel_button = QPushButton("Cancel Upload", parent=self)
self.cancel_button.setIcon(material_icon("cancel"))
self.cancel_button.clicked.connect(self.cancel_requested.emit)
button_height = int(spinner_size * 0.9)
button_height = max(36, min(button_height, 72))
aspect_ratio = 3.8 # width / height, visually stable for text buttons
button_width = int(button_height * aspect_ratio)
self.cancel_button.setFixedSize(button_width, button_height)
color = get_accent_colors()
self.cancel_button.setStyleSheet(f"""
QPushButton {{
background-color: {color.emergency.name()};
color: white;
font-weight: 600;
border-radius: 6px;
}}
""")
# Layout
content_layout = QVBoxLayout(self)
content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(16)
content_layout.addStretch()
content_layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter)
content_layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignHCenter)
content_layout.addStretch()
content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter)
if hasattr(color, "_colors"):
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"""
background-color: {bg_color.name()};
border-radius: 12px;
""")
def _ui_scale(self) -> int:
parent = self.parent()
if not parent:
return 0
return min(parent.width(), parent.height())
def showEvent(self, event):
"""Show event to start the spinner."""
super().showEvent(event)
self.spinner.start()
def hideEvent(self, event):
"""Hide event to stop the spinner."""
super().hideEvent(event)
self.spinner.stop()
class DeviceManagerDisplayWidget(DockAreaWidget):
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
@@ -57,13 +155,23 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
request_ophyd_validation = Signal(list, bool, bool)
def __init__(self, parent=None, client=None, *args, **kwargs):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, variant="compact", *args, **kwargs)
# State variable for config upload
self._config_upload_active: bool = False
self._config_in_sync: bool = False
scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status())
initial_status = scan_status.status if scan_status is not None else "closed"
self._scan_is_running: bool = initial_status in ["open", "paused"]
# Push to Redis dialog
self._upload_redis_dialog: UploadRedisDialog | None = None
self._dialog_validation_connection: QMetaObject.Connection | None = None
# NOTE: We need here a separate config helper instance to avoid conflicts with
# other communications to REDIS as uploading a config through a CommunicationConfigAction
# will block if we use the config_helper from self.client.config._config_helper
self._config_helper = config_helper.ConfigHelper(self.client.connector)
self._shared_selection = SharedSelectionSignal()
@@ -107,23 +215,62 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
(self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)),
(
self.device_table_view.device_configs_changed,
(self.ophyd_test_view.change_device_configs,),
(self.ophyd_test_view.device_table_config_changed,),
),
(
self.device_table_view.device_config_in_sync_with_redis,
(self._update_config_enabled_button,),
(self._update_config_in_sync,),
),
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
]:
for slot in slots:
signal.connect(slot)
self._scan_status_callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.SCAN_STATUS, self._update_scan_running
)
# Add toolbar
self._add_toolbar()
# Build dock layout using shared helpers
self._build_docks()
def cleanup(self):
self.bec_dispatcher.client.callbacks.remove(self._scan_status_callback_id)
super().cleanup()
def closeEvent(self, event):
"""If config upload is active when application is exiting, cancel it."""
logger.info("Application is quitting, checking for active config upload...")
if self._config_upload_active:
logger.info("Application is quitting, cancelling active config upload...")
self._config_helper.send_config_request(
action="cancel", config=None, wait_for_response=True, timeout_s=10
)
logger.info("Config upload cancelled.")
super().closeEvent(event)
##############################
### Custom set busy widget ###
##############################
def create_busy_state_widget(self) -> QWidget:
"""Create a custom busy state widget for uploading device configurations."""
widget = CustomBusyWidget(parent=self, client=self.client)
widget.cancel_requested.connect(self._cancel_device_config_upload)
return widget
def _set_busy_wrapper(self, enabled: bool):
"""Thin wrapper around set_busy to flip the state variable."""
self._busy_overlay.set_opacity(0.92)
self._config_upload_active = enabled
self.set_busy(enabled=enabled)
##############################
### Toolbar and Dock setup ###
##############################
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
@@ -305,6 +452,36 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
######################################
### Update button state management ###
######################################
@SafeSlot(dict, dict)
def _update_scan_running(self, scan_info: dict, _: dict):
"""disable editing when scans are running and enable editing when they are finished"""
msg = ScanStatusMessage.model_validate(scan_info)
self._scan_is_running = msg.status in ["open", "paused"]
self._update_config_enabled_button()
def _update_config_in_sync(self, in_sync: bool):
self._config_in_sync = in_sync
self._update_config_enabled_button()
def _update_config_enabled_button(self):
action = self.toolbar.components.get_action("update_config_redis")
enabled = not self._config_in_sync and not self._scan_is_running
action.action.setEnabled(enabled)
if enabled: # button is enabled
action.action.setToolTip("Push current config to BEC Server")
elif self._scan_is_running:
action.action.setToolTip("Scan is currently running, config updates disabled.")
else:
action.action.setToolTip("Current config is in sync with BEC Server, updates disabled.")
#######################
### Action Handlers ###
#######################
@SafeSlot()
@SafeSlot(bool)
def _run_validate_connection(self, connect: bool = True):
@@ -312,16 +489,15 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
configs = list(self.device_table_view.get_selected_device_configs())
if not configs:
configs = self.device_table_view.get_device_config()
# Adjust the state of the icons in the device table view
self.device_table_view.update_multiple_device_validations(
[
(cfg, ConfigStatus.UNKNOWN.value, ConnectionStatus.UNKNOWN.value, "")
for cfg in configs
]
)
self.request_ophyd_validation.emit(configs, True, connect)
def _update_config_enabled_button(self, enabled: bool):
action = self.toolbar.components.get_action("update_config_redis")
action.action.setEnabled(not enabled)
if enabled:
action.action.setToolTip("Push current config to BEC Server")
else:
action.action.setToolTip("Current config is in sync with BEC Server, button disabled.")
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
@@ -424,17 +600,15 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
"Do you really want to flush the current config in BEC Server?",
)
if reply == QMessageBox.StandardButton.Yes:
self.set_busy(enabled=True, text="Flushing configuration in BEC Server...")
self.client.config.reset_config()
logger.info("Successfully flushed configuration in BEC Server.")
self.set_busy(enabled=False)
# Check if config is in sync, enable load redis button
self.device_table_view.device_config_in_sync_with_redis.emit(
self.device_table_view._is_config_in_sync_with_redis()
)
validation_results = self.device_table_view.get_validation_results()
for config, config_status, connnection_status in validation_results.values():
if connnection_status == ConnectionStatus.CONNECTED.value:
for config, config_status, connection_status in validation_results.values():
if connection_status == ConnectionStatus.CONNECTED.value:
self.device_table_view.update_device_validation(
config, config_status, ConnectionStatus.CAN_CONNECT, ""
)
@@ -474,7 +648,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
validation_results = self.device_table_view.get_validation_results()
# Create and show upload dialog
self._upload_redis_dialog = UploadRedisDialog(
parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view
parent=self, device_configs=validation_results
)
self._upload_redis_dialog.request_ophyd_validation.connect(
self.request_ophyd_validation.emit
)
# Show dialog
@@ -484,6 +661,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
self._push_composition_to_redis(action="set")
elif reply == UploadRedisDialog.UploadAction.CANCEL:
self.ophyd_test_view.cancel_all_validations()
elif reply == UploadRedisDialog.UploadAction.CONNECTION_TEST_REQUESTED:
return QMessageBox.information(
self, "Connection Test Requested", "Running connection test on untested devices."
)
def _push_composition_to_redis(self, action: ConfigAction):
"""Push the current device composition to Redis."""
@@ -496,12 +677,37 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
comm.signals.done.connect(self._handle_push_complete_to_communicator)
comm.signals.error.connect(self._handle_exception_from_communicator)
threadpool.start(comm)
self.set_busy(enabled=True, text="Uploading configuration to BEC Server...")
self._set_busy_wrapper(enabled=True)
def _cancel_device_config_upload(self):
"""Cancel the device configuration upload process."""
threadpool = QThreadPool.globalInstance()
comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel")
# Cancelling will raise an exception in the communicator, so we connect to the failure handler
comm.signals.error.connect(self._handle_cancel_config_upload_failed)
threadpool.start(comm)
def _handle_cancel_config_upload_failed(self, exception: Exception):
"""Handle failure to cancel the config upload."""
self._set_busy_wrapper(enabled=False)
validation_results = self.device_table_view.get_validation_results()
devices_to_update = []
for config, config_status, connection_status in validation_results.values():
devices_to_update.append(
(config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled")
)
# Rerun validation of all devices after cancellation
self.device_table_view.update_multiple_device_validations(devices_to_update)
self.ophyd_test_view.change_device_configs(
[cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False
)
# Config is in sync with BEC, so we update the state
self.device_table_view.device_config_in_sync_with_redis.emit(False)
def _handle_push_complete_to_communicator(self):
"""Handle completion of the config push to Redis."""
self.set_busy(enabled=False)
self._update_validation_icons_after_upload()
self._set_busy_wrapper(enabled=False)
def _handle_exception_from_communicator(self, exception: Exception):
"""Handle exceptions from the config communicator."""
@@ -510,22 +716,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
"Error Uploading Config",
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
)
self.set_busy(enabled=False)
self._update_validation_icons_after_upload()
def _update_validation_icons_after_upload(self):
"""Update validation icons after uploading config to Redis."""
if self.client.device_manager is None:
return
device_names_in_session = list(self.client.device_manager.devices.keys())
validation_results = self.device_table_view.get_validation_results()
devices_to_update = []
for config, config_status, connection_status in validation_results.values():
if config["name"] in device_names_in_session:
devices_to_update.append(
(config, config_status, ConnectionStatus.CONNECTED.value, "")
)
self.device_table_view.update_multiple_device_validations(devices_to_update)
self._set_busy_wrapper(enabled=False)
@SafeSlot()
def _save_to_disk_action(self):
@@ -591,7 +782,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
):
if old_device_name and old_device_name != data.get("name", ""):
self.device_table_view.remove_device(old_device_name)
self.device_table_view.update_device_configs([data])
self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name)
@SafeSlot(dict, int, int, str, str)
def _add_to_table_from_dialog(
@@ -602,7 +793,15 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
msg: str,
old_device_name: str = "",
):
self.device_table_view.add_device_configs([data])
if connection_status == ConnectionStatus.UNKNOWN.value:
self.device_table_view.update_device_configs([data], skip_validation=False)
else: # Connection status was tested in dialog
# If device is connected, we remove it from the ophyd validation view
self.device_table_view.update_device_configs([data], skip_validation=True)
# Update validation status in device table view and ophyd validation view
self.ophyd_test_view._on_device_test_completed(
data, config_status, connection_status, msg
)
@SafeSlot()
def _remove_device_action(self):

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

@@ -2,7 +2,8 @@ from __future__ import annotations
from typing import List
from qtpy.QtCore import QEventLoop, Qt, QTimer
from pydantic import BaseModel
from qtpy.QtCore import QEventLoop
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
@@ -11,55 +12,31 @@ from qtpy.QtWidgets import (
QLabel,
QMessageBox,
QPushButton,
QSplitter,
QStackedLayout,
QVBoxLayout,
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
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
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.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
view_title: str
step_ids: List[str]
class ViewBase(QWidget):
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.
@@ -67,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)
@@ -115,67 +99,40 @@ class ViewBase(QWidget):
"""
return True
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
@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.")
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
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.
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
return None
####################################################################################################
@@ -203,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)
@@ -225,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()
@@ -350,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

@@ -10,9 +10,9 @@ import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
@@ -232,6 +232,11 @@ class BECGuiClient(RPCBase):
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
"""Check if already registered for registration in idempotent functions."""
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
self._client.connector.register(endpoint, cb=cb, **kwargs)
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
@@ -247,10 +252,9 @@ class BECGuiClient(RPCBase):
self._ipython_registry = {}
# Register the new callback
self._client.connector.register(
self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
@@ -297,38 +301,119 @@ 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",
startup_profile: str | Literal["restore", "skip"] | None = None,
**kwargs,
) -> client.BECDockArea:
) -> client.AdvancedDockArea:
"""Create a new top-level dock area.
Args:
name(str, optional): The name of the dock area. Defaults to None.
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)
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".
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.BECDockArea: The new dock area.
client.AdvancedDockArea: The new dock area.
Examples:
>>> 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, name=name, geometry=geometry, **kwargs
) # pylint: disable=protected-access
return widget
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
return self._new_impl(
name=name,
geometry=geometry,
launch_script=launch_script,
startup_profile=startup_profile,
**kwargs,
)
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,
startup_profile=startup_profile,
**kwargs,
) # pylint: disable=protected-access
return widget
def delete(self, name: str) -> None:
"""Delete a dock area.
"""Delete a dock area and its parent window.
Args:
name(str): The name of the dock area.
@@ -336,7 +421,19 @@ class BECGuiClient(RPCBase):
widget = self.windows.get(name)
if widget is None:
raise ValueError(f"Dock area {name} not found.")
widget._run_rpc("close") # pylint: disable=protected-access
# Get the container_proxy (parent window) gui_id from the server registry
obj = self._server_registry.get(widget._gui_id)
if obj is None:
raise ValueError(f"Widget {name} not found in registry.")
container_gui_id = obj.get("container_proxy")
if container_gui_id:
# Close the container window which will also clean up the dock area
widget._run_rpc("close", gui_id=container_gui_id) # pylint: disable=protected-access
else:
# Fallback: just close the dock area directly
widget._run_rpc("close") # pylint: disable=protected-access
def delete_all(self) -> None:
"""Delete all dock areas."""
@@ -438,20 +535,14 @@ class BECGuiClient(RPCBase):
def _start(self, wait: bool = False) -> None:
self._killed = False
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
return self._start_server(wait=wait)
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)

View File

@@ -94,7 +94,7 @@ logger = bec_logger.logger
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
...
"""
@@ -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

@@ -248,9 +248,7 @@ class RPCBase:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
@@ -276,11 +274,10 @@ class RPCBase:
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
parent._msg_wait_event.set()
self._rpc_response = msg
self._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -292,6 +289,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

@@ -5,14 +5,13 @@ from threading import RLock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
import shiboken6 as shb
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
@@ -109,11 +108,19 @@ class RPCRegister:
dict: A dictionary containing all the registered RPC objects.
"""
with self._lock:
connections = dict(self._rpc_register)
connections = {}
for gui_id, obj in self._rpc_register.items():
try:
if not shb.isValid(obj):
continue
connections[gui_id] = obj
except Exception as e:
logger.warning(f"Error checking validity of object {gui_id}: {e}")
continue
return connections
def get_names_of_rpc_by_class_type(
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
self, cls: type[BECWidget] | type[BECConnector]
) -> list[str]:
"""Get all the names of the widgets.

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,22 +103,24 @@ 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)
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
if self.gui_class:
self.launcher_window = LaunchWindow(
gui_id=f"{self.gui_id}:launcher",
launch_gui_class=self.gui_class,
launch_gui_id=self.gui_class_id,
)
else:
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
self.app.setQuitOnLastWindowClosed(True)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
@@ -125,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)
@@ -147,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

@@ -25,11 +25,9 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
@@ -366,15 +364,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
# clean up any widgets that might have custom cleanup
try:
# call cleanup on known containers if present
dock = self._widgets_by_name.get("dock")
if isinstance(dock, BECDockArea):
dock.cleanup()
dock.close()
except Exception:
pass
# Ensure the embedded kernel and BEC client are shut down before window teardown
self.console.shutdown_kernel()

View File

@@ -1,5 +1,7 @@
# pylint: skip-file
from unittest.mock import MagicMock
from bec_lib.config_helper import ConfigHelper
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
@@ -24,6 +26,16 @@ class FakeDevice(BECDevice):
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
self.name: {
"kind_str": "hinted",
"component_name": self.name,
"obj_name": self.name,
"signal_class": "Signal",
}
}
}
@property
def readout_priority(self):
@@ -208,7 +220,9 @@ class Device(FakeDevice):
class DMMock:
def __init__(self):
def __init__(self, *args, **kwargs):
self._service = args[0]
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
@@ -255,6 +269,17 @@ class DMMock:
signals.append((device_name, signal_name, signal_info))
return signals
def _get_redis_device_config(self) -> list[dict]:
"""Mock method to emulate DeviceManager._get_redis_device_config."""
configs = []
for device in self.devices.values():
configs.append(device._config)
return configs
def initialize(*_): ...
def shutdown(self): ...
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),

View File

@@ -8,20 +8,21 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
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 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
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.name_utils import sanitize_namespace
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
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",))
@@ -86,8 +87,9 @@ class BECConnector:
config: ConnectionConfig | None = None,
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
rpc_exposed: bool = True,
rpc_passthrough_children: bool = True,
**kwargs,
):
"""
@@ -98,12 +100,17 @@ class BECConnector:
config(ConnectionConfig, optional): The connection configuration with specific gui id.
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
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
object_name = object_name or kwargs.pop("objectName", None)
if object_name is not None:
object_name = sanitize_namespace(object_name)
# Ensure the parent is always the first argument for QObject
parent = kwargs.pop("parent", None)
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
@@ -119,7 +126,6 @@ class BECConnector:
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
self.rpc_register = RPCRegister()
if not self.client in BECConnector.EXIT_HANDLERS:
@@ -127,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
@@ -156,7 +167,7 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# If the gui_id is passed, it should be respected. However, this should be revisted since
# If the gui_id is passed, it should be respected. However, this should be revisited since
# the gui_id has to be unique, and may no longer be.
if gui_id:
self.config.gui_id = gui_id
@@ -184,19 +195,54 @@ 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)
QTimer.singleShot(0, self._update_object_name)
self._update_object_name()
@property
def parent_id(self) -> str | None:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
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.
@@ -206,7 +252,7 @@ class BECConnector:
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
self._update_object_name()
def _update_object_name(self) -> None:
"""
@@ -215,11 +261,13 @@ 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:
except RuntimeError as e:
logger.warning(f"Error emitting name_established signal: {e}")
return
def _enforce_unique_sibling_name(self):
@@ -230,23 +278,20 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.sendPostedEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if not shb.isValid(self):
return
parent_bec = WidgetHierarchy.get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
siblings = parent_bec.findChildren(BECConnector)
siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)]
else:
# No parent => treat all top-level BECConnectors as siblings
# 1) Gather all BECConnectors from QApplication
all_widgets = QApplication.allWidgets()
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
# 2) "Top-level" means closest BECConnector parent is None
top_level_bec = [
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
]
# 3) We are among these top-level siblings
siblings = top_level_bec
# 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]
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
@@ -274,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):
@@ -352,7 +399,7 @@ class BECConnector:
"""
self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
# FIXME some thoughts are required to decide how this should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
@@ -370,7 +417,7 @@ class BECConnector:
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
# FIXME some thoughts are required to decide how this should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
@@ -456,12 +503,8 @@ class BECConnector:
def remove(self):
"""Cleanup the BECConnector"""
# If the widget is attached to a dock, remove it from the dock.
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
if self._parent_dock is not None:
self._parent_dock.delete(self.object_name)
# If the widget is from Qt, trigger its close method.
elif hasattr(self, "close"):
if hasattr(self, "close"):
self.close()
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
# i.e. Curve Item from Waveform
@@ -485,6 +528,62 @@ class BECConnector:
else:
return self.config
def export_settings(self) -> dict:
"""
Export the settings of the widget as dict.
Returns:
dict: The exported settings of the widget.
"""
# We first get all qproperties that were defined in a bec_widgets class
objs = self._get_bec_meta_objects()
settings = {}
for prop_name in objs.keys():
try:
prop_value = getattr(self, prop_name)
settings[prop_name] = prop_value
except Exception as e:
logger.warning(
f"Could not export property '{prop_name}' from '{self.__class__.__name__}': {e}"
)
return settings
def load_settings(self, settings: dict) -> None:
"""
Load the settings of the widget from dict.
Args:
settings (dict): The settings to load into the widget.
"""
objs = self._get_bec_meta_objects()
for prop_name, prop_value in settings.items():
if prop_name in objs:
try:
setattr(self, prop_name, prop_value)
except Exception as e:
logger.warning(
f"Could not load property '{prop_name}' into '{self.__class__.__name__}': {e}"
)
def _get_bec_meta_objects(self) -> dict:
"""
Get BEC meta objects for the widget.
Returns:
dict: BEC meta objects.
"""
if not isinstance(self, QObject):
return {}
objects = {}
for name, attr in vars(self.__class__).items():
if isinstance(attr, Property):
# Check if the property is a SafeProperty
is_safe_property = getattr(attr.fget, "__is_safe_getter__", False)
if is_safe_property:
objects[name] = attr
return objects
# --- Example usage of BECConnector: running a simple task ---
if __name__ == "__main__": # pragma: no cover

View File

@@ -123,17 +123,16 @@ class BECDispatcher:
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if self.client is None:
if config is not None:
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
if client is None:
if config is not None and not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClient(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
else:
self.client = client
if self.client.started:
# have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector")
@@ -176,12 +175,15 @@ class BECDispatcher:
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
else:
logger.warning(f"Attempted to create duplicate stream subscription for {topics=}")
def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]

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

@@ -6,17 +6,20 @@ from typing import TYPE_CHECKING
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from qtpy.QtGui import QFont, QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.busy_loader import install_busy_loader
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -38,8 +41,6 @@ class BECWidget(BECConnector):
gui_id: str | None = None,
theme_update: bool = False,
start_busy: bool = False,
busy_text: str = "Loading…",
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
**kwargs,
):
"""
@@ -58,9 +59,7 @@ class BECWidget(BECConnector):
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
super().__init__(
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
if theme_update:
@@ -68,18 +67,14 @@ class BECWidget(BECConnector):
self._connect_to_theme_change()
# Initialize optional busy loader overlay utility (lazy by default)
self._busy_overlay = None
self._busy_overlay: "BusyLoaderOverlay" | None = None
self._busy_state_widget: QWidget | None = None
self._loading = False
self._busy_overlay = self._install_busy_loader()
if start_busy and isinstance(self, QWidget):
try:
overlay = self._ensure_busy_overlay(busy_text=busy_text)
if overlay is not None:
overlay.setGeometry(self.rect())
overlay.raise_()
overlay.show()
self._loading = True
except Exception as exc:
logger.debug(f"Busy loader init skipped: {exc}")
self._show_busy_overlay()
self._loading = True
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
@@ -100,48 +95,109 @@ class BECWidget(BECConnector):
self._update_overlay_theme(theme)
self.apply_theme(theme)
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
"""Create the busy overlay on demand and cache it in _busy_overlay.
def create_busy_state_widget(self) -> QWidget:
"""
Method to create a custom busy state widget to be shown in the busy overlay.
Child classes should overrid this method to provide a custom widget if desired.
Returns:
QWidget: The custom busy state widget.
NOTE:
The implementation here is a SpinnerWidget with a "Loading..." label. This is the default
busy state widget for all BECWidgets. However, child classes with specific needs for the
busy state can easily overrite this method to provide a custom widget. The signature of
the method must be preserved to ensure compatibility with the busy overlay system. If
the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up.
The widget may connect to the _busy_overlay signals foreground_color_changed and
scrim_color_changed to update its colors when the theme changes.
"""
# Widget
class BusyStateWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# label
label = QLabel("Loading...", self)
label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = QFont(label.font())
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
label.setFont(f)
# spinner
spinner = SpinnerWidget(self)
spinner.setFixedSize(42, 42)
# Layout
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 24, 24, 24)
lay.setSpacing(10)
lay.addStretch(1)
lay.addWidget(spinner, 0, Qt.AlignHCenter)
lay.addWidget(label, 0, Qt.AlignHCenter)
lay.addStretch(1)
self.setLayout(lay)
def showEvent(self, event):
"""Show event to start the spinner."""
super().showEvent(event)
for child in self.findChildren(SpinnerWidget):
child.start()
def hideEvent(self, event):
"""Hide event to stop the spinner."""
super().hideEvent(event)
for child in self.findChildren(SpinnerWidget):
child.stop()
widget = BusyStateWidget(self)
return widget
def _install_busy_loader(self) -> "BusyLoaderOverlay" | None:
"""
Create the busy overlay on demand and cache it in _busy_overlay.
Returns the overlay instance or None if not a QWidget.
"""
if not isinstance(self, QWidget):
return None
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
from bec_widgets.utils.busy_loader import install_busy_loader
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
overlay = install_busy_loader(target=self, start_loading=False)
self._busy_overlay = overlay
# Create and set the busy state widget
self._busy_state_widget = self.create_busy_state_widget()
self._busy_overlay.set_widget(self._busy_state_widget)
return overlay
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
def _show_busy_overlay(self) -> None:
"""Create and attach the loading overlay to this widget if QWidget is present."""
if not isinstance(self, QWidget):
return
self._ensure_busy_overlay(busy_text=busy_text)
if start_busy and self._busy_overlay is not None:
self._busy_overlay.setGeometry(self.rect())
if self._busy_overlay is not None:
self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member
self._busy_overlay.raise_()
self._busy_overlay.show()
def set_busy(self, enabled: bool, text: str | None = None) -> None:
def set_busy(self, enabled: bool) -> None:
"""
Enable/disable the loading overlay. Optionally update the text.
Set the busy state of the widget. This will show or hide the loading overlay, which will
block user interaction with the widget and show the busy_state_widget if provided. Per
default, the busy state widget is a spinner with "Loading..." text.
Args:
enabled(bool): Whether to enable the loading overlay.
text(str, optional): The text to display on the overlay. If None, the text is not changed.
enabled(bool): Whether to enable the busy state.
"""
if not isinstance(self, QWidget):
return
if getattr(self, "_busy_overlay", None) is None:
self._ensure_busy_overlay(busy_text=text or "Loading…")
if text is not None:
self.set_busy_text(text)
# If not yet installed, install the busy overlay now together with the busy state widget
if self._busy_overlay is None:
self._busy_overlay = self._install_busy_loader()
if enabled:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
self._show_busy_overlay()
else:
self._busy_overlay.hide()
self._loading = bool(enabled)
@@ -155,19 +211,6 @@ class BECWidget(BECConnector):
"""
return bool(getattr(self, "_loading", False))
def set_busy_text(self, text: str) -> None:
"""
Update the text on the loading overlay.
Args:
text(str): The text to display on the overlay.
"""
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
overlay = self._ensure_busy_overlay(busy_text=text)
if overlay is not None:
overlay.set_text(text)
@SafeSlot(str)
def apply_theme(self, theme: str):
"""
@@ -180,8 +223,8 @@ class BECWidget(BECConnector):
def _update_overlay_theme(self, theme: str):
try:
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and hasattr(overlay, "update_palette"):
overlay.update_palette()
if overlay is not None:
overlay._update_palette()
except Exception:
logger.warning(f"Failed to apply theme {theme} to {self}")
@@ -307,10 +350,13 @@ class BECWidget(BECConnector):
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
overlay.cleanup()
overlay.deleteLater()
except Exception as exc:
logger.warning(f"Failed to delete busy overlay: {exc}")
self._busy_overlay = None
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
from qtpy.QtGui import QColor, QFont
from bec_lib.logger import bec_logger
from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QFrame,
@@ -13,10 +14,10 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
class _OverlayEventFilter(QObject):
@@ -28,6 +29,10 @@ class _OverlayEventFilter(QObject):
self._overlay = overlay
def eventFilter(self, obj, event):
if not hasattr(self, "_target") or self._target is None:
return False
if not hasattr(self, "_overlay") or self._overlay is None:
return False
if obj is self._target and event.type() in (
QEvent.Resize,
QEvent.Show,
@@ -53,132 +58,201 @@ class BusyLoaderOverlay(QWidget):
BusyLoaderOverlay: The overlay instance.
"""
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
foreground_color_changed = Signal(QColor)
scrim_color_changed = Signal(QColor)
def __init__(self, parent: QWidget, opacity: float = 0.35, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setAutoFillBackground(False)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self._opacity = opacity
self._scrim_color = QColor(128, 128, 128, 110)
self._label_color = QColor(240, 240, 240)
self._filter: QObject | None = None
self._label = QLabel(text, self)
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = QFont(self._label.font())
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
self._label.setFont(f)
# Set Main Layout
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(10)
self.setLayout(layout)
self._spinner = SpinnerWidget(self)
self._spinner.setFixedSize(42, 42)
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 24, 24, 24)
lay.setSpacing(10)
lay.addStretch(1)
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
lay.addWidget(self._label, 0, Qt.AlignHCenter)
lay.addStretch(1)
# Custom widget placeholder
self._custom_widget: QWidget | None = None
# Add a frame around the content
self._frame = QFrame(self)
self._frame.setObjectName("busyFrame")
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self._frame.lower()
# Defaults
self._scrim_color = QColor(0, 0, 0, 110)
self._label_color = QColor(240, 240, 240)
self.update_palette()
self._update_palette()
# Start hidden; interactions beneath are blocked while visible
self.hide()
# --- API ---
def set_text(self, text: str):
@SafeProperty(QColor, notify=scrim_color_changed)
def scrim_color(self) -> QColor:
"""
Update the overlay text.
The overlay scrim color.
"""
return self._scrim_color
@scrim_color.setter
def scrim_color(self, value: QColor):
if not isinstance(value, QColor):
raise TypeError("scrim_color must be a QColor")
self._scrim_color = value
self.update()
@SafeProperty(QColor, notify=foreground_color_changed)
def foreground_color(self) -> QColor:
"""
The overlay foreground color (text, spinner).
"""
return self._label_color
@foreground_color.setter
def foreground_color(self, value: QColor):
if not isinstance(value, QColor):
try:
color = QColor(value)
if not color.isValid():
raise ValueError(f"Invalid color: {value}")
except Exception:
# pylint: disable=raise-missing-from
raise ValueError(f"Color {value} is invalid, cannot be converted to QColor")
self._label_color = value
self.update()
def set_filter(self, filt: _OverlayEventFilter):
"""
Set an event filter to keep the overlay sized and stacked over its target.
Args:
text(str): The text to display on the overlay.
filt(QObject): The event filter instance.
"""
self._label.setText(text)
self._filter = filt
target = filt._target
if self.parent() != target:
logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}")
target.installEventFilter(self._filter)
######################
### Public methods ###
######################
def set_widget(self, widget: QWidget):
"""
Set a custom widget as an overlay for the busy overlay.
Args:
widget(QWidget): The custom widget to display.
"""
lay = self.layout()
if lay is None:
return
self._custom_widget = widget
lay.addWidget(widget, 0, Qt.AlignHCenter)
def set_opacity(self, opacity: float):
"""
Set overlay opacity (0..1).
Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a
value outside this range is provided, it will be clamped.
Args:
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
"""
self._opacity = max(0.0, min(1.0, float(opacity)))
# Re-apply alpha using the current theme color
if isinstance(self._scrim_color, QColor):
base = QColor(self._scrim_color)
base.setAlpha(int(255 * self._opacity))
self._scrim_color = base
self.update()
base = self.scrim_color
base.setAlpha(int(255 * self._opacity))
self.scrim_color = base
self._update_palette()
def update_palette(self):
##########################
### Internal methods ###
##########################
def _update_palette(self):
"""
Update colors from the current application theme.
"""
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme # type: ignore[attr-defined]
self._bg = theme.color("BORDER")
self._fg = theme.color("FG")
self._primary = theme.color("PRIMARY")
_app = QApplication.instance()
if hasattr(_app, "theme"):
theme = _app.theme # type: ignore[attr-defined]
_bg = theme.color("BORDER")
_fg = theme.color("FG")
else:
# Fallback neutrals
self._bg = QColor(30, 30, 30)
self._fg = QColor(230, 230, 230)
_bg = QColor(30, 30, 30)
_fg = QColor(230, 230, 230)
# Semi-transparent scrim derived from bg
self._scrim_color = QColor(self._bg)
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
self._spinner.update()
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
self._label.setStyleSheet(f"color: {fg_hex};")
base = _bg if isinstance(_bg, QColor) else QColor(str(_bg))
base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
self.scrim_color = base
fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg))
self.foreground_color = fg
# Set the frame style with updated foreground colors
r, g, b, a = base.getRgb()
self._frame.setStyleSheet(
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}"
)
self.update()
# --- QWidget overrides ---
#############################
### Custom Event Handlers ###
#############################
def showEvent(self, e):
self._spinner.start()
# Call showEvent on custom widget if present
if self._custom_widget is not None:
self._custom_widget.showEvent(e)
super().showEvent(e)
def hideEvent(self, e):
self._spinner.stop()
# Call hideEvent on custom widget if present
if self._custom_widget is not None:
self._custom_widget.hideEvent(e)
super().hideEvent(e)
def resizeEvent(self, e):
# Call resizeEvent on custom widget if present
if self._custom_widget is not None:
self._custom_widget.resizeEvent(e)
super().resizeEvent(e)
r = self.rect().adjusted(10, 10, -10, -10)
self._frame.setGeometry(r)
def paintEvent(self, e):
super().paintEvent(e)
# TODO should we have this cleanup here?
def cleanup(self):
"""Cleanup resources used by the overlay."""
if self._custom_widget is not None:
if hasattr(self._custom_widget, "cleanup"):
self._custom_widget.cleanup()
def install_busy_loader(
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
target: QWidget, start_loading: bool = False, opacity: float = 0.35
) -> BusyLoaderOverlay:
"""
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
Args:
target(QWidget): The widget to overlay.
text(str): Initial text to display.
start_loading(bool): If True, show the overlay immediately.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
overlay = BusyLoaderOverlay(parent=target, opacity=opacity)
overlay.setGeometry(target.rect())
filt = _OverlayEventFilter(target, overlay)
overlay._filter = filt # type: ignore[attr-defined]
target.installEventFilter(filt)
overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay))
if start_loading:
overlay.show()
return overlay
@@ -187,65 +261,63 @@ def install_busy_loader(
# --------------------------
# Launchable demo
# --------------------------
class DemoWidget(BECWidget, QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
)
self._title = QLabel("Demo Content", self)
self._title.setAlignment(Qt.AlignCenter)
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
lay = QVBoxLayout(self)
lay.addWidget(self._title)
waveform = Waveform(self)
waveform.plot([1, 2, 3, 4, 5])
lay.addWidget(waveform, 1)
QTimer.singleShot(5000, self._ready)
def _ready(self):
self._title.setText("Ready ✓")
self.set_busy(False)
class DemoWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Busy Loader — BECWidget demo")
left = DemoWidget()
right = DemoWidget()
btn_on = QPushButton("Right → Loading")
btn_off = QPushButton("Right → Ready")
btn_text = QPushButton("Set custom text")
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
btn_off.clicked.connect(lambda: right.set_busy(False))
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
panel = QWidget()
prow = QVBoxLayout(panel)
prow.addWidget(btn_on)
prow.addWidget(btn_off)
prow.addWidget(btn_text)
prow.addStretch(1)
central = QWidget()
row = QHBoxLayout(central)
row.setContentsMargins(12, 12, 12, 12)
row.setSpacing(12)
row.addWidget(left, 1)
row.addWidget(right, 1)
row.addWidget(panel, 0)
self.setCentralWidget(central)
self.resize(900, 420)
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class DemoWidget(BECWidget, QWidget): # pragma: no cover
def __init__(self, parent=None, start_busy: bool = False):
super().__init__(parent=parent, theme_update=True, start_busy=start_busy)
self._title = QLabel("Demo Content", self)
self._title.setAlignment(Qt.AlignCenter)
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
lay = QVBoxLayout(self)
lay.addWidget(self._title)
waveform = Waveform(self)
waveform.plot([1, 2, 3, 4, 5])
lay.addWidget(waveform, 1)
QTimer.singleShot(5000, self._ready)
def _ready(self):
self._title.setText("Ready ✓")
self.set_busy(False)
class DemoWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Busy Loader — BECWidget demo")
left = DemoWidget(start_busy=True)
right = DemoWidget()
btn_on = QPushButton("Right → Loading")
btn_off = QPushButton("Right → Ready")
btn_text = QPushButton("Set custom text")
btn_on.clicked.connect(lambda: right.set_busy(True))
btn_off.clicked.connect(lambda: right.set_busy(False))
panel = QWidget()
prow = QVBoxLayout(panel)
prow.addWidget(btn_on)
prow.addWidget(btn_off)
prow.addWidget(btn_text)
prow.addStretch(1)
central = QWidget()
row = QHBoxLayout(central)
row.setContentsMargins(12, 12, 12, 12)
row.setSpacing(12)
row.addWidget(left, 1)
row.addWidget(right, 1)
row.addWidget(panel, 0)
self.setCentralWidget(central)
self.resize(900, 420)
app = QApplication(sys.argv)
apply_theme("light")
w = DemoWindow()

View File

@@ -1,17 +1,22 @@
from __future__ import annotations
import re
from functools import lru_cache
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_qthemes import apply_theme as apply_theme_global
from bec_qthemes._theme import AccentColors
from pydantic_core import PydanticCustomError
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
logger = bec_logger.logger
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
@@ -47,12 +52,103 @@ def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
"""
logger.info(f"Applying theme: {theme}")
process_all_deferred_deletes(QApplication.instance())
apply_theme_global(theme)
process_all_deferred_deletes(QApplication.instance())
class Colors:
@staticmethod
def list_available_colormaps() -> list[str]:
"""
List colormap names available via the pyqtgraph colormap registry.
Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus).
"""
def _list(source: str | None = None) -> list[str]:
try:
return pg.colormap.listMaps() if source is None else pg.colormap.listMaps(source)
except Exception: # pragma: no cover - backend may be missing
return []
return [*_list(None), *_list("matplotlib"), *_list("colorcet")]
@staticmethod
def list_available_gradient_presets() -> list[str]:
"""
List `GradientEditorItem` preset names (HistogramLUT right-click menu entries).
"""
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
return list(Gradients.keys())
@staticmethod
def canonical_colormap_name(color_map: str) -> str:
"""
Return an available colormap/preset name if a case-insensitive match exists.
"""
requested = (color_map or "").strip()
if not requested:
return requested
registry = Colors.list_available_colormaps()
presets = Colors.list_available_gradient_presets()
available = set(registry) | set(presets)
if requested in available:
return requested
# Case-insensitive match.
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:
"""
Resolve a string into a `pg.ColorMap` using either:
- the `pg.colormap` registry (optionally including matplotlib/colorcet backends), or
- `GradientEditorItem` presets (HistogramLUT right-click menu).
"""
name = Colors.canonical_colormap_name(color_map)
if not name:
raise ValueError("Empty colormap name")
return Colors._get_colormap_cached(name)
@staticmethod
@lru_cache(maxsize=256)
def _get_colormap_cached(name: str) -> pg.ColorMap:
# 1) Registry/backends
try:
cmap = pg.colormap.get(name)
if cmap is not None:
return cmap
except Exception:
pass
for source in ("matplotlib", "colorcet"):
try:
cmap = pg.colormap.get(name, source=source)
if cmap is not None:
return cmap
except Exception:
continue
# 2) Presets -> ColorMap
if name not in Gradients:
raise KeyError(f"Colormap '{name}' not found")
ge = pg.GradientEditorItem()
ge.loadPreset(name)
return ge.colorMap()
@staticmethod
def golden_ratio(num: int) -> list:
@@ -134,7 +230,7 @@ class Colors:
if theme_offset < 0 or theme_offset > 1:
raise ValueError("theme_offset must be between 0 and 1")
cmap = pg.colormap.get(colormap)
cmap = Colors.get_colormap(colormap)
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
# Generate positions that are evenly spaced within the acceptable range
@@ -182,7 +278,7 @@ class Colors:
ValueError: If theme_offset is not between 0 and 1.
"""
cmap = pg.colormap.get(colormap)
cmap = Colors.get_colormap(colormap)
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
@@ -448,18 +544,103 @@ class Colors:
Raises:
PydanticCustomError: If colormap is invalid.
"""
available_pg_maps = pg.colormap.listMaps()
available_mpl_maps = pg.colormap.listMaps("matplotlib")
available_mpl_colorcet = pg.colormap.listMaps("colorcet")
available_colormaps = available_pg_maps + available_mpl_maps + available_mpl_colorcet
if color_map not in available_colormaps:
normalized = Colors.canonical_colormap_name(color_map)
try:
Colors.get_colormap(normalized)
except Exception as ext:
logger.warning(f"Colormap validation error: {ext}")
if return_error:
available_colormaps = sorted(
set(Colors.list_available_colormaps())
| set(Colors.list_available_gradient_presets())
)
raise PydanticCustomError(
"unsupported colormap",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.",
{"wrong_value": color_map},
)
else:
return False
return color_map
return normalized
@staticmethod
def relative_luminance(color: QColor) -> float:
"""
Calculate the relative luminance of a QColor according to WCAG 2.0 standards.
See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance.
Args:
color(QColor): The color to calculate the relative luminance for.
Returns:
float: The relative luminance of the color.
"""
r = color.red() / 255.0
g = color.green() / 255.0
b = color.blue() / 255.0
def adjust(c):
if c <= 0.03928:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
r = adjust(r)
g = adjust(g)
b = adjust(b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
@staticmethod
def _tint_strength(
accent: QColor, background: QColor, min_tint: float = 0.06, max_tint: float = 0.18
) -> float:
"""
Calculate the tint strength based on the contrast between the accent and background colors.
min_tint and max_tint define the range of tint strength and are empirically chosen.
Args:
accent(QColor): The accent color.
background(QColor): The background color.
min_tint(float): The minimum tint strength.
max_tint(float): The maximum tint strength.
Returns:
float: The tint strength between 0 and 1.
"""
l_accent = Colors.relative_luminance(accent)
l_bg = Colors.relative_luminance(background)
contrast = abs(l_accent - l_bg)
# normalize contrast to a value between 0 and 1
t = min(contrast / 0.9, 1.0)
return min_tint + t * (max_tint - min_tint)
@staticmethod
def _blend(background: QColor, accent: QColor, t: float) -> QColor:
"""
Blend two colors based on a tint strength t.
"""
return QColor(
round(background.red() + (accent.red() - background.red()) * t),
round(background.green() + (accent.green() - background.green()) * t),
round(background.blue() + (accent.blue() - background.blue()) * t),
round(background.alpha() + (accent.alpha() - background.alpha()) * t),
)
@staticmethod
def subtle_background_color(accent: QColor, background: QColor) -> QColor:
"""
Generate a subtle, contrast-safe background color derived from an accent color.
Args:
accent(QColor): The accent color.
background(QColor): The background color.
Returns:
QColor: The generated subtle background color.
"""
if not accent.isValid() or not background.isValid():
return background
tint = Colors._tint_strength(accent, background)
return Colors._blend(background, accent, tint)

View File

@@ -43,7 +43,7 @@ class WidgetContainerUtils:
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
while ii < 1000: # 1000 is arbitrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate

View File

@@ -1,19 +1,38 @@
import functools
import sys
import traceback
from typing import Any, Callable, Literal
import shiboken6
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QLabel,
QMessageBox,
QPushButton,
QSpinBox,
QTabWidget,
QVBoxLayout,
QWidget,
)
logger = bec_logger.logger
RAISE_ERROR_DEFAULT = False
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
def SafeProperty(
prop_type,
*prop_args,
popup_error: bool = False,
default: Any = None,
auto_emit: bool = False,
emit_value: Literal["stored", "input"] | Callable[[object, object], object] = "stored",
emit_on_change: bool = True,
**prop_kwargs,
):
"""
Decorator to create a Qt Property with safe getter and setter so that
Qt Designer won't crash if an exception occurs in either method.
@@ -22,7 +41,15 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
default: Any default/fallback value to return if the getter raises an exception.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
auto_emit (bool): If True, automatically emit property_changed signal when setter is called.
Requires the widget to have a property_changed signal (str, object).
Note: This is different from Qt's 'notify' parameter which expects a Signal.
emit_value: Controls which value is emitted when auto_emit=True.
- "stored" (default): emit the value from the getter after setter runs
- "input": emit the raw setter input
- callable: called as emit_value(self_, value) after setter and must return the value to emit
emit_on_change (bool): If True, emit only when the stored value changes.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor (check https://doc.qt.io/qt-6/properties.html).
Usage:
@SafeProperty(int, default=-1)
@@ -34,6 +61,41 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
def some_value(self, val: int):
# your setter logic
...
# With auto-emit for toolbar sync:
@SafeProperty(bool, auto_emit=True)
def fft(self) -> bool:
return self._fft
@fft.setter
def fft(self, value: bool):
self._fft = value
# property_changed.emit("fft", value) is called automatically
# With custom emit modes:
@SafeProperty(int, auto_emit=True, emit_value="stored")
def precision_stored(self) -> int:
return self._precision_stored
@precision_stored.setter
def precision_stored(self, value: int):
self._precision_stored = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value="input")
def precision_input(self) -> int:
return self._precision_input
@precision_input.setter
def precision_input(self, value: int):
self._precision_input = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10)
def precision_callable(self) -> int:
return self._precision_callable
@precision_callable.setter
def precision_callable(self, value: int):
self._precision_callable = max(0, int(value))
"""
def decorator(py_getter):
@@ -53,6 +115,8 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
return default
safe_getter.__is_safe_getter__ = True # type: ignore[attr-defined]
class PropertyWrapper:
"""
Intermediate wrapper used so that the user can optionally chain .setter(...).
@@ -68,8 +132,42 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
@functools.wraps(setter_func)
def safe_setter(self_, value):
try:
return setter_func(self_, value)
except Exception:
before_value = None
if auto_emit and emit_on_change:
try:
before_value = self.getter_func(self_)
except Exception as e:
logger.warning(
f"SafeProperty could not get 'before' value for change detection: {e}"
)
before_value = None
result = setter_func(self_, value)
# Auto-emit property_changed if auto_emit=True and signal exists
if auto_emit and hasattr(self_, "property_changed"):
prop_name = py_getter.__name__
try:
if callable(emit_value):
emit_payload = emit_value(self_, value)
elif emit_value == "input":
emit_payload = value
else:
emit_payload = self.getter_func(self_)
if emit_on_change and before_value == emit_payload:
return result
self_.property_changed.emit(prop_name, emit_payload)
except Exception as notify_error:
# Don't fail the setter if notification fails
logger.warning(
f"SafeProperty auto_emit failed for '{prop_name}': {notify_error}"
)
return result
except Exception as e:
logger.warning(f"SafeProperty setter caught exception: {e}")
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
error_msg = traceback.format_exc()
@@ -335,6 +433,100 @@ def ErrorPopupUtility():
return _popup_utility_instance
class SafePropertyExampleWidget(QWidget): # pragma: no cover
"""
Example widget showcasing SafeProperty auto_emit modes.
"""
property_changed = Signal(str, object)
def __init__(self):
super().__init__()
self.setWindowTitle("SafeProperty auto_emit example")
self._precision_stored = 0
self._precision_input = 0
self._precision_callable = 0
layout = QVBoxLayout(self)
self.status = QLabel("last emit: <none>", self)
self.spinbox_stored = QSpinBox(self)
self.spinbox_stored.setRange(-5, 10)
self.spinbox_stored.setValue(0)
self.label_stored = QLabel("stored emit: <none>", self)
self.spinbox_input = QSpinBox(self)
self.spinbox_input.setRange(-5, 10)
self.spinbox_input.setValue(0)
self.label_input = QLabel("input emit: <none>", self)
self.spinbox_callable = QSpinBox(self)
self.spinbox_callable.setRange(-5, 10)
self.spinbox_callable.setValue(0)
self.label_callable = QLabel("callable emit: <none>", self)
layout.addWidget(QLabel("stored emit (normalized value):", self))
layout.addWidget(self.spinbox_stored)
layout.addWidget(self.label_stored)
layout.addWidget(QLabel("input emit (raw setter input):", self))
layout.addWidget(self.spinbox_input)
layout.addWidget(self.label_input)
layout.addWidget(QLabel("callable emit (custom mapping):", self))
layout.addWidget(self.spinbox_callable)
layout.addWidget(self.label_callable)
layout.addWidget(self.status)
self.spinbox_stored.valueChanged.connect(self._on_spinbox_stored)
self.spinbox_input.valueChanged.connect(self._on_spinbox_input)
self.spinbox_callable.valueChanged.connect(self._on_spinbox_callable)
self.property_changed.connect(self._on_property_changed)
@SafeProperty(int, auto_emit=True, emit_value="stored", doc="Clamped precision value.")
def precision_stored(self) -> int:
return self._precision_stored
@precision_stored.setter
def precision_stored(self, value: int):
self._precision_stored = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value="input", doc="Emit raw input value.")
def precision_input(self) -> int:
return self._precision_input
@precision_input.setter
def precision_input(self, value: int):
self._precision_input = max(0, int(value))
@SafeProperty(int, auto_emit=True, emit_value=lambda _self, v: int(v) * 10)
def precision_callable(self) -> int:
return self._precision_callable
@precision_callable.setter
def precision_callable(self, value: int):
self._precision_callable = max(0, int(value))
def _on_spinbox_stored(self, value: int):
self.precision_stored = value
def _on_spinbox_input(self, value: int):
self.precision_input = value
def _on_spinbox_callable(self, value: int):
self.precision_callable = value
def _on_property_changed(self, prop_name: str, value):
self.status.setText(f"last emit: {prop_name}={value}")
if prop_name == "precision_stored":
self.label_stored.setText(f"stored emit: {value}")
elif prop_name == "precision_input":
self.label_input.setText(f"input emit: {value}")
elif prop_name == "precision_callable":
self.label_callable.setText(f"callable emit: {value}")
class ExampleWidget(QWidget): # pragma: no cover
"""
Example widget to demonstrate error handling with the ErrorPopupUtility.
@@ -389,6 +581,10 @@ class ExampleWidget(QWidget): # pragma: no cover
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
widget = ExampleWidget()
widget.show()
tabs = QTabWidget()
tabs.setWindowTitle("Error Popups & SafeProperty Examples")
tabs.addTab(ExampleWidget(), "Error Popups")
tabs.addTab(SafePropertyExampleWidget(), "SafeProperty auto_emit")
tabs.resize(420, 520)
tabs.show()
sys.exit(app.exec_())

View File

@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from typeguard import TypeCheckError
from bec_widgets.utils.ophyd_kind_util import Kind
@@ -55,6 +56,49 @@ class WidgetFilterHandler(ABC):
"""
# This method should be implemented in subclasses or extended as needed
def update_with_bec_signal_class(
self,
signal_class_filter: str | list[str],
client,
ndim_filter: int | list[int] | None = None,
) -> list[tuple[str, str, dict]]:
"""Update the selection based on signal classes using device_manager.get_bec_signals.
Args:
signal_class_filter (str|list[str]): List of signal class names to filter.
client: BEC client instance.
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
If provided, only signals with matching ndim will be included.
Returns:
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
"""
if not client or not hasattr(client, "device_manager"):
return []
try:
signals = client.device_manager.get_bec_signals(signal_class_filter)
except TypeCheckError as e:
logger.warning(f"Error retrieving signals: {e}")
return []
if ndim_filter is None:
return signals
if isinstance(ndim_filter, int):
ndim_filter = [ndim_filter]
filtered_signals = []
for device_name, signal_name, signal_config in signals:
ndim = None
if isinstance(signal_config, dict):
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
if ndim in ndim_filter:
filtered_signals.append((device_name, signal_name, signal_config))
return filtered_signals
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
@@ -255,6 +299,32 @@ class FilterIO:
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def update_with_signal_class(
widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None
) -> list[tuple[str, str, dict]]:
"""
Update the selection based on signal classes using device_manager.get_bec_signals.
Args:
widget: Widget instance.
signal_class_filter (list[str]): List of signal class names to filter.
client: BEC client instance.
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
If provided, only signals with matching ndim will be included.
Returns:
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_bec_signal_class(
signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def _find_handler(widget):
"""

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

@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
forms generated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
@abstractmethod
def _add_main_widget(self) -> None:
self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and appply any
"""Add the main data entry widget to self._main_widget and apply any
constraints from the field info"""
@SafeSlot()
@@ -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

@@ -0,0 +1,35 @@
"""Module providing fuzzy search utilities for the BEC widgets."""
from __future__ import annotations
from typing import Any
from thefuzz import fuzz
FUZZY_SEARCH_THRESHOLD = 80
def is_match(
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
) -> bool:
"""
Check if the text matches any of the relevant keys in the row data.
Args:
text (str): The text to search for.
row_data (dict[str, Any]): The row data to search in.
relevant_keys (list[str]): The keys to consider for searching.
enable_fuzzy (bool): Whether to use fuzzy matching.
Returns:
bool: True if a match is found, False otherwise.
"""
for key in relevant_keys:
data = str(row_data.get(key, "") or "")
if enable_fuzzy:
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text.lower() in data.lower():
return True
return False

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

@@ -14,3 +14,22 @@ def pascal_to_snake(name: str) -> str:
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()
def sanitize_namespace(namespace: str | None) -> str | None:
"""
Clean user-provided namespace labels for filesystem compatibility.
Args:
namespace (str | None): Arbitrary namespace identifier supplied by the caller.
Returns:
str | None: Sanitized namespace containing only safe characters, or ``None``
when the input is empty.
"""
if not namespace:
return None
ns = namespace.strip()
if not ns:
return None
return re.sub(r"[^0-9A-Za-z._-]+", "_", ns)

View File

@@ -15,7 +15,7 @@ class Kind(IFBase):
"""
This is used in the .kind attribute of all OphydObj (Signals, Devices).
A Device examines its components' .kind atttribute to decide whether to
A Device examines its components' .kind attribute to decide whether to
traverse it in read(), read_configuration(), or neither. Additionally, if
decides whether to include its name in `hints['fields']`.
"""

View File

@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
def createWidget(self, parent):
if parent is None:
return QWidget()
return QWidget()
t = {plugin_name_pascal}(parent)
return t

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

@@ -1,5 +1,6 @@
import os
import sys
from typing import Any
from PIL import Image, ImageChops
from qtpy.QtGui import QPixmap
@@ -40,7 +41,7 @@ def compare_images(image1_path: str, reference_image_path: str):
raise ValueError("Images are different")
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
def snap_and_compare(widget: Any, output_directory: str, suffix: str = ""):
"""
Save a rendering of a widget and compare it to a reference image

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

@@ -1,25 +1,27 @@
from __future__ import annotations
import functools
import time
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 QApplication
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
@@ -32,6 +34,10 @@ logger = bec_logger.logger
T = TypeVar("T")
class RegistryNotReadyError(Exception):
"""Raised when trying to access an object from the RPC registry that is not yet registered."""
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
@@ -55,6 +61,19 @@ def rpc_exception_hook(err_func):
popup.custom_exception_hook = old_exception_hook
class SingleshotRPCRepeat:
def __init__(self, max_delay: int = 2000):
self.max_delay = max_delay
self.accumulated_delay = 0
def __iadd__(self, delay: int):
self.accumulated_delay += delay
if self.accumulated_delay > self.max_delay:
raise RegistryNotReadyError("Max delay exceeded for RPC singleshot repeat")
return self
class RPCServer:
client: BECClient
@@ -86,6 +105,7 @@ class RPCServer:
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self._rpc_singleshot_repeats: dict[str, SingleshotRPCRepeat] = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
@@ -98,18 +118,22 @@ 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"])
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}")
self.send_response(request_id, False, {"error": content})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
@@ -132,7 +156,7 @@ class RPCServer:
if method == "raise" and hasattr(
obj, "setWindowState"
): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
# The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
@@ -157,24 +181,149 @@ 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)
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.
Note: If the object is not yet registered in the RPC registry, this method
will retry serialization after a short delay, up to a maximum delay. In order
to avoid processEvents calls in the middle of serialization, QTimer.singleShot is used.
This allows the target event to 'float' to the next event loop iteration until the
object is registered.
The 'jump' to the next event loop is indicated by raising a RegistryNotReadyError, see
_serialize_bec_connector.
Args:
request_id (str): The ID of the request.
res (object): The result of the RPC call.
"""
retry_delay = 100
try:
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
except RegistryNotReadyError:
try:
self._rpc_singleshot_repeats[request_id] += retry_delay
QTimer.singleShot(
retry_delay, lambda: self.serialize_result_and_send(request_id, res)
)
except RegistryNotReadyError:
logger.error(
f"Max delay exceeded for RPC request {request_id}, sending error response"
)
self.send_response(
request_id,
False,
{
"error": f"Max delay exceeded for RPC request {request_id}, object not registered in time."
},
)
self._rpc_singleshot_repeats.pop(request_id, None)
return
except Exception as exc:
logger.error(f"Error while serializing RPC result: {exc}")
self.send_response(
request_id,
False,
{"error": f"Error while serializing RPC result: {exc}\n{traceback.format_exc()}"},
)
else:
self.send_response(request_id, True, {"result": res})
self._rpc_singleshot_repeats.pop(request_id, None)
def serialize_object(self, obj: T) -> None | dict | T:
"""
@@ -191,6 +340,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:
@@ -219,6 +371,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
@@ -256,11 +410,8 @@ class RPCServer:
except Exception:
container_proxy = None
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
logger.info(f"Waiting for {connector} to be registered...")
time.sleep(0.1)
if wait and not self.rpc_register.object_is_registered(connector):
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
@@ -272,23 +423,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:
"""
@@ -305,5 +442,5 @@ class RPCServer:
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
logger.info("Succeeded in shutting down CLI server")
self.client.shutdown()

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

@@ -26,6 +26,7 @@ from qtpy.QtWidgets import (
)
import bec_widgets
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
@@ -34,16 +35,19 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
def create_action_with_text(toolbar_action, toolbar: QToolBar):
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
"""
Helper function to create a toolbar button with text beside or under the icon.
Args:
toolbar_action(ToolBarAction): The toolbar action to create the button for.
toolbar(ModularToolBar): The toolbar to add the button to.
min_size(QSize, optional): The minimum size for the button. Defaults to None.
"""
btn = QToolButton(parent=toolbar)
if min_size is not None:
btn.setMinimumSize(min_size)
if getattr(toolbar_action, "label_text", None):
toolbar_action.action.setText(toolbar_action.label_text)
if getattr(toolbar_action, "tooltip", None):
@@ -498,6 +502,82 @@ class WidgetAction(ToolBarAction):
return max_width + 60
class SplitterAction(ToolBarAction):
"""
Action for adding a draggable splitter/spacer to the toolbar.
This creates a resizable spacer that allows users to control how much space
is allocated to toolbar sections before and after it. When dragged, it expands/contracts,
pushing other toolbar elements left or right.
Args:
orientation (Literal["horizontal", "vertical", "auto"]): The orientation of the splitter.
parent (QWidget): The parent widget.
initial_width (int): Fixed size of the spacer in pixels along the toolbar's orientation (default: 20).
min_width (int | None): Minimum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no minimum constraint is applied.
max_width (int | None): Maximum size of the target widget along the orientation axis (width for horizontal, height for vertical). If ``None``, no maximum constraint is applied.
target_widget (QWidget | None): Widget whose size (width or height, depending on orientation) is controlled by the spacer within the given min/max bounds.
"""
def __init__(
self,
orientation: Literal["horizontal", "vertical", "auto"] = "auto",
parent=None,
initial_width=20,
min_width: int | None = None,
max_width: int | None = None,
target_widget=None,
):
super().__init__(icon_path=None, tooltip="Drag to resize toolbar sections", checkable=False)
self.orientation = orientation
self.initial_width = initial_width
self.min_width = min_width
self.max_width = max_width
self._splitter_widget = None
self._target_widget = target_widget
def _resolve_orientation(self, toolbar: QToolBar) -> Literal["horizontal", "vertical"]:
if self.orientation in (None, "auto"):
return (
"horizontal" if toolbar.orientation() == Qt.Orientation.Horizontal else "vertical"
)
return self.orientation
def set_target_widget(self, widget):
"""Set the target widget after creation."""
self._target_widget = widget
if self._splitter_widget:
self._splitter_widget.set_target_widget(widget)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the splitter/spacer to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the splitter to.
target (QWidget): The target widget for the action.
"""
effective_orientation = self._resolve_orientation(toolbar)
self._splitter_widget = ResizableSpacer(
parent=target,
orientation=effective_orientation,
initial_width=self.initial_width,
min_target_size=self.min_width,
max_target_size=self.max_width,
target_widget=self._target_widget,
)
toolbar.addWidget(self._splitter_widget)
self.action = self._splitter_widget # type: ignore
def cleanup(self):
"""Clean up the splitter widget."""
if self._splitter_widget is not None:
self._splitter_widget.close()
self._splitter_widget.deleteLater()
return super().cleanup()
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
@@ -522,16 +602,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

@@ -7,10 +7,17 @@ from weakref import ReferenceType
import louie
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
from bec_widgets.utils.toolbars.actions import SeparatorAction, SplitterAction, ToolBarAction
DEFAULT_SIZE = 400
MAX_SIZE = 10_000_000
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@@ -195,6 +202,84 @@ class ToolbarBundle:
"""
self.add_action("separator")
def add_splitter(
self,
name: str = "splitter",
target_widget: QWidget | None = None,
initial_width: int = 10,
min_width: int | None = None,
max_width: int | None = None,
size_policy_expanding: bool = True,
):
"""
Adds a resizable splitter action to the bundle.
Args:
name (str): Unique identifier for the splitter action.
target_widget (QWidget, optional): The widget whose size (width for horizontal,
height for vertical orientation) will be controlled by the splitter. If None,
the splitter will not control any widget.
initial_width (int): The initial size of the splitter (width for horizontal,
height for vertical orientation).
min_width (int, optional): The minimum size the target widget can be resized to
(width for horizontal, height for vertical orientation). If None, the target
widget's minimum size hint in that orientation will be used.
max_width (int, optional): The maximum size the target widget can be resized to
(width for horizontal, height for vertical orientation). If None, the target
widget's maximum size hint in that orientation will be used.
size_policy_expanding (bool): If True, the size policy of the target_widget will be
set to Expanding in the appropriate orientation if it is not already set.
"""
# Resolve effective bounds
eff_min = min_width if min_width is not None else None
eff_max = max_width if max_width is not None else None
is_horizontal = self.components.toolbar.orientation() == Qt.Orientation.Horizontal
if target_widget is not None:
# Use widget hints if bounds not provided
if eff_min is None:
eff_min = (
target_widget.minimumWidth() if is_horizontal else target_widget.minimumHeight()
) or 6
if eff_max is None:
mw = (
target_widget.maximumWidth() if is_horizontal else target_widget.maximumHeight()
)
eff_max = mw if mw and mw < MAX_SIZE else DEFAULT_SIZE # avoid "no limit"
# Adjust size policy if needed
if size_policy_expanding:
size_policy = target_widget.sizePolicy()
if is_horizontal:
if size_policy.horizontalPolicy() not in (
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.MinimumExpanding,
):
size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
target_widget.setSizePolicy(size_policy)
else:
if size_policy.verticalPolicy() not in (
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.MinimumExpanding,
):
size_policy.setVerticalPolicy(QSizePolicy.Policy.Expanding)
target_widget.setSizePolicy(size_policy)
splitter_action = SplitterAction(
orientation="auto",
parent=self.components.toolbar,
initial_width=initial_width,
min_width=eff_min,
max_width=eff_max,
target_widget=target_widget,
)
self.components.add_safe(name, splitter_action)
self.add_action(name)
def add_connection(self, name: str, connection):
"""
Adds a connection to the bundle.

View File

@@ -1,18 +1,136 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
logger = bec_logger.logger
class BundleConnection(QObject):
"""
Base class for toolbar bundle connections.
Provides infrastructure for bidirectional property-toolbar synchronization:
- Toolbar actions → Widget properties (via action.triggered connections)
- Widget properties → Toolbar actions (via property_changed signal)
"""
bundle_name: str
def __init__(self, parent=None):
super().__init__(parent)
self._property_sync_methods: dict[str, Callable] = {}
self._property_sync_connected = False
def register_property_sync(self, prop_name: str, sync_method: Callable):
"""
Register a method to synchronize toolbar state when a property changes.
This enables automatic toolbar updates when properties are set programmatically,
restored from QSettings, or changed via RPC.
Args:
prop_name: The property name to watch (e.g., "fft", "log", "x_grid")
sync_method: Method to call when property changes. Should accept the new value
and update toolbar state (typically with signals blocked to prevent loops)
Example:
def _sync_fft_toolbar(self, value: bool):
self.fft_action.blockSignals(True)
self.fft_action.setChecked(value)
self.fft_action.blockSignals(False)
self.register_property_sync("fft", self._sync_fft_toolbar)
"""
self._property_sync_methods[prop_name] = sync_method
def _resolve_action(self, action_like):
if hasattr(action_like, "action"):
return action_like.action
return action_like
def register_checked_action_sync(self, prop_name: str, action_like):
"""
Register a property sync for a checkable QAction (or wrapper with .action).
This reduces boilerplate for simple boolean → checked state updates.
"""
qt_action = self._resolve_action(action_like)
def _sync_checked(value):
qt_action.blockSignals(True)
try:
qt_action.setChecked(bool(value))
finally:
qt_action.blockSignals(False)
self.register_property_sync(prop_name, _sync_checked)
def connect_property_sync(self, target_widget):
"""
Connect to target widget's property_changed signal for automatic toolbar sync.
Call this in your connect() method after registering all property syncs.
Args:
target_widget: The widget to monitor for property changes
"""
if self._property_sync_connected:
return
if hasattr(target_widget, "property_changed"):
target_widget.property_changed.connect(self._on_property_changed)
self._property_sync_connected = True
else:
logger.warning(
f"{target_widget.__class__.__name__} does not have property_changed signal. "
"Property-toolbar sync will not work."
)
def disconnect_property_sync(self, target_widget):
"""
Disconnect from target widget's property_changed signal.
Call this in your disconnect() method.
Args:
target_widget: The widget to stop monitoring
"""
if not self._property_sync_connected:
return
if hasattr(target_widget, "property_changed"):
try:
target_widget.property_changed.disconnect(self._on_property_changed)
except (RuntimeError, TypeError):
# Signal already disconnected or connection doesn't exist
pass
self._property_sync_connected = False
def _on_property_changed(self, prop_name: str, value):
"""
Internal handler for property changes.
Calls the registered sync method for the changed property.
"""
if prop_name in self._property_sync_methods:
try:
self._property_sync_methods[prop_name](value)
except Exception as e:
logger.error(
f"Error syncing toolbar for property '{prop_name}': {e}", exc_info=True
)
@abstractmethod
def connect(self):
"""
Connects the bundle to the target widget or application.
This method should be implemented by subclasses to define how the bundle interacts with the target.
Subclasses should call connect_property_sync(target_widget) if property sync is needed.
"""
@abstractmethod
@@ -20,4 +138,6 @@ class BundleConnection(QObject):
"""
Disconnects the bundle from the target widget or application.
This method should be implemented by subclasses to define how to clean up connections.
Subclasses should call disconnect_property_sync(target_widget) if property sync was connected.
"""

View File

@@ -0,0 +1,239 @@
"""
Draggable splitter for toolbars to allow resizing of toolbar sections.
"""
from typing import Literal
from bec_qthemes import material_icon
from qtpy.QtCore import QPoint, QSize, Qt, Signal
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QSizePolicy, QWidget
class ResizableSpacer(QWidget):
"""
A resizable spacer widget for toolbars that can be dragged to expand/contract.
When connected to a widget, it controls that widget's size along the spacer's
orientation (maximum width for horizontal, maximum height for vertical),
ensuring the widget stays flush against the spacer with no gaps.
Args:
parent(QWidget | None): Parent widget.
orientation(Literal["horizontal", "vertical"]): Orientation of the spacer.
initial_width(int): Initial size of the spacer in pixels along the orientation
(width for horizontal, height for vertical).
min_target_size(int): Minimum size of the target widget when resized along the
orientation (width for horizontal, height for vertical).
max_target_size(int): Maximum size of the target widget when resized along the
orientation (width for horizontal, height for vertical).
target_widget: QWidget | None. The widget whose size along the orientation
is controlled by this spacer.
"""
size_changed = Signal(int)
def __init__(
self,
parent=None,
orientation: Literal["horizontal", "vertical"] = "horizontal",
initial_width: int = 10,
min_target_size: int = 6,
max_target_size: int = 500,
target_widget: QWidget = None,
):
from bec_widgets.utils.toolbars.bundles import DEFAULT_SIZE, MAX_SIZE
super().__init__(parent)
self._target_start_size = None
self.orientation = orientation
self._current_width = initial_width
self._min_width = min_target_size
self._max_width = max_target_size
self._dragging = False
self._drag_start_pos = QPoint()
self._target_widget = target_widget
# Determine bounds from kwargs or target hints
is_horizontal = orientation == "horizontal"
target_min = target_widget.minimumWidth() if (target_widget and is_horizontal) else 0
if target_widget and not is_horizontal:
target_min = target_widget.minimumHeight()
target_hint = target_widget.sizeHint().width() if (target_widget and is_horizontal) else 0
if target_widget and not is_horizontal:
target_hint = target_widget.sizeHint().height()
target_max_hint = (
target_widget.maximumWidth() if (target_widget and is_horizontal) else None
)
if target_widget and not is_horizontal:
target_max_hint = target_widget.maximumHeight()
self._min_target = min_target_size if min_target_size is not None else (target_min or 6)
self._max_target = (
max_target_size
if max_target_size is not None
else (
target_max_hint if target_max_hint and target_max_hint < MAX_SIZE else DEFAULT_SIZE
)
)
# Determine a reasonable base width and clamp to bounds
if target_widget:
current_size = target_widget.width() if is_horizontal else target_widget.height()
if current_size > 0:
self._base_width = current_size
elif target_min > 0:
self._base_width = target_min
elif target_hint > 0:
self._base_width = target_hint
else:
self._base_width = 240
else:
self._base_width = 240
self._base_width = max(self._min_target, min(self._max_target, self._base_width))
# Set size constraints - Fixed policy to prevent automatic resizing
# Match toolbar height for proper alignment
self._toolbar_height = 32 # Standard toolbar height
if orientation == "horizontal":
self.setFixedWidth(initial_width)
self.setFixedHeight(self._toolbar_height)
self.setCursor(Qt.CursorShape.SplitHCursor)
else:
self.setFixedHeight(initial_width)
self.setFixedWidth(self._toolbar_height)
self.setCursor(Qt.CursorShape.SplitVCursor)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setStyleSheet("""
ResizableSpacer {
background-color: transparent;
margin: 0px;
padding: 0px;
border: none;
}
ResizableSpacer:hover {
background-color: rgba(100, 100, 200, 80);
}
""")
self.setContentsMargins(0, 0, 0, 0)
if self._target_widget:
size_policy = self._target_widget.sizePolicy()
if is_horizontal:
vertical_policy = size_policy.verticalPolicy()
self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy)
else:
horizontal_policy = size_policy.horizontalPolicy()
self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed)
# Load Material icon based on orientation
icon_name = "more_vert" if orientation == "horizontal" else "more_horiz"
icon_size = 24
self._icon = material_icon(icon_name, size=(icon_size, icon_size), convert_to_pixmap=False)
self._icon_size = icon_size
def set_target_widget(self, widget):
"""Set the widget whose size is controlled by this spacer."""
self._target_widget = widget
if widget:
is_horizontal = self.orientation == "horizontal"
target_min = widget.minimumWidth() if is_horizontal else widget.minimumHeight()
target_hint = widget.sizeHint().width() if is_horizontal else widget.sizeHint().height()
target_max_hint = widget.maximumWidth() if is_horizontal else widget.maximumHeight()
self._min_target = self._min_target or (target_min or 6)
self._max_target = (
self._max_target
if self._max_target is not None
else (target_max_hint if target_max_hint and target_max_hint < 10_000_000 else 400)
)
current_size = widget.width() if is_horizontal else widget.height()
if current_size is not None and current_size > 0:
base = current_size
elif target_min is not None and target_min > 0:
base = target_min
elif target_hint is not None and target_hint > 0:
base = target_hint
else:
base = self._base_width
base = max(self._min_target, min(self._max_target, base))
if is_horizontal:
widget.setFixedWidth(base)
else:
widget.setFixedHeight(base)
def get_target_widget(self):
"""Get the widget whose size is controlled by this spacer."""
return self._target_widget
def sizeHint(self):
if self.orientation == "horizontal":
return QSize(self._current_width, self._toolbar_height)
else:
return QSize(self._toolbar_height, self._current_width)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Draw the Material icon centered in the widget using stored icon size
x = (self.width() - self._icon_size) // 2
y = (self.height() - self._icon_size) // 2
self._icon.paint(painter, x, y, self._icon_size, self._icon_size)
painter.end()
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = True
self._drag_start_pos = event.globalPos()
# Store target's current width if it exists
if self._target_widget:
if self.orientation == "horizontal":
self._target_start_size = self._target_widget.width()
else:
self._target_start_size = self._target_widget.height()
size_policy = self._target_widget.sizePolicy()
if self.orientation == "horizontal":
vertical_policy = size_policy.verticalPolicy()
self._target_widget.setSizePolicy(QSizePolicy.Policy.Fixed, vertical_policy)
self._target_widget.setFixedWidth(self._target_start_size)
else:
horizontal_policy = size_policy.horizontalPolicy()
self._target_widget.setSizePolicy(horizontal_policy, QSizePolicy.Policy.Fixed)
self._target_widget.setFixedHeight(self._target_start_size)
event.accept()
def mouseMoveEvent(self, event):
if self._dragging:
current_pos = event.globalPos()
delta = current_pos - self._drag_start_pos
if self.orientation == "horizontal":
delta_pixels = delta.x()
else:
delta_pixels = delta.y()
if self._target_widget:
new_target_size = self._target_start_size + delta_pixels
new_target_size = max(self._min_target, min(self._max_target, new_target_size))
if self.orientation == "horizontal":
if new_target_size != self._target_widget.width():
self._target_widget.setFixedWidth(new_target_size)
self.size_changed.emit(new_target_size)
else:
if new_target_size != self._target_widget.height():
self._target_widget.setFixedHeight(new_target_size)
self.size_changed.emit(new_target_size)
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = False
event.accept()

View File

@@ -8,10 +8,19 @@ from typing import DefaultDict, Literal
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QLabel,
QMainWindow,
QMenu,
QToolBar,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import apply_theme, get_theme_name
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -282,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);
@@ -291,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);
@@ -304,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:
@@ -406,9 +411,18 @@ class ModularToolBar(QToolBar):
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no non-separator actions between them.
Hide separators that are adjacent to another separator, splitters, or have no non-separator actions between them.
Splitters (ResizableSpacer) already provide visual separation, so we don't need separators next to them.
"""
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
toolbar_actions = self.actions()
# Helper function to check if a widget is a splitter
def is_splitter_widget(action):
widget = self.widgetForAction(action)
return widget is not None and isinstance(widget, ResizableSpacer)
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
@@ -423,23 +437,32 @@ class ModularToolBar(QToolBar):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
# Hide separator if adjacent to another separator, splitter, or at edges
if (
prev_visible is None
or prev_visible.isSeparator()
or is_splitter_widget(prev_visible)
) and (
next_visible is None
or next_visible.isSeparator()
or is_splitter_widget(next_visible)
):
action.setVisible(False)
else:
action.setVisible(True)
# Second pass: ensure no two visible separators are adjacent.
# Second pass: ensure no two visible separators are adjacent, and no separators next to splitters.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
if action.isVisible():
if action.isSeparator():
# Hide separator if previous visible item was a separator or splitter
if prev and (prev.isSeparator() or is_splitter_widget(prev)):
action.setVisible(False)
else:
prev = action
else:
prev = action
else:
if action.isVisible():
prev = action
if not toolbar_actions:
return
@@ -481,12 +504,31 @@ if __name__ == "__main__": # pragma: no cover
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.test_label = QLabel(text="This is a test label.")
self.test_label = QLabel(text="Drag the splitter (⋮) to resize!")
self.central_widget.layout = QVBoxLayout(self.central_widget)
self.central_widget.layout.addWidget(self.test_label)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(self.toolbar)
# Example: Bare combobox (no container). Give it a stable starting width
self.example_combo = QComboBox(parent=self)
self.example_combo.addItems(["device_1", "device_2", "device_3"])
self.toolbar.components.add_safe(
"example_combo", WidgetAction(widget=self.example_combo)
)
# Create a bundle with the combobox and a splitter
self.bundle_combo_splitter = ToolbarBundle("example_combo", self.toolbar.components)
self.bundle_combo_splitter.add_action("example_combo")
# Add splitter; target the bare widget
self.bundle_combo_splitter.add_splitter(
name="splitter_example", target_widget=self.example_combo, min_width=100
)
# Add other bundles
self.toolbar.add_bundle(self.bundle_combo_splitter)
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
@@ -502,7 +544,9 @@ if __name__ == "__main__": # pragma: no cover
text_position="under",
),
)
self.toolbar.show_bundles(["performance", "plot_export"])
# Show bundles - notice how performance and plot_export appear compactly after splitter!
self.toolbar.show_bundles(["example_combo", "performance", "plot_export"])
self.toolbar.get_bundle("performance").add_action("save")
self.toolbar.get_bundle("performance").add_action("text")
self.toolbar.refresh()

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,12 +7,12 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.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
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
@@ -24,7 +24,7 @@ logger = bec_logger.logger
class AutoUpdates(BECMainWindow):
_default_dock: BECDock
_default_dock: CDockWidget | None
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
PLUGIN = False
@@ -37,11 +37,16 @@ class AutoUpdates(BECMainWindow):
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
self.dock_area = BECDockArea(
parent=self,
object_name="dock_area",
enable_profile_management=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
@@ -58,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
@@ -106,9 +111,11 @@ class AutoUpdates(BECMainWindow):
"""
Create a default dock for the auto updates.
"""
self.dock_area.delete_all()
self.dock_name = "update_dock"
self._default_dock = self.dock_area.new(self.dock_name)
self.current_widget = self._default_dock.new("Waveform")
self.current_widget = self.dock_area.new("Waveform")
docks = self.dock_area.dock_list()
self._default_dock = docks[0] if docks else None
@overload
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
@@ -138,16 +145,18 @@ class AutoUpdates(BECMainWindow):
Returns:
BECWidget: The widget that was set.
"""
if self._default_dock is None or self.current_widget is None:
if self.current_widget is None:
logger.warning(
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
)
self.start_default_dock()
assert self.current_widget is not None
if not self.current_widget.__class__.__name__ == widget:
self._default_dock.delete(self.current_widget.object_name)
self.current_widget = self._default_dock.new(widget)
if self.current_widget.__class__.__name__ != widget:
self.dock_area.delete_all()
self.current_widget = self.dock_area.new(widget)
docks = self.dock_area.dock_list()
self._default_dock = docks[0] if docks else None
return self.current_widget
def get_selected_device(
@@ -235,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
@@ -247,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,
@@ -256,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:
@@ -270,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
@@ -279,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:
@@ -297,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
@@ -308,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

@@ -1,2 +0,0 @@
from .dock import BECDock
from .dock_area import BECDockArea

View File

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

View File

@@ -1,440 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class DockConfig(ConnectionConfig):
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
"bottom", description="The position of the dock."
)
parent_dock_area: Optional[str] | None = Field(
None, description="The GUI ID of parent dock area of the dock."
)
class CustomDockLabel(DockLabel):
def __init__(self, text: str, closable: bool = True):
super().__init__(text, closable)
if closable:
red_icon = QtGui.QIcon()
pixmap = QtGui.QPixmap(32, 32)
pixmap.fill(QtCore.Qt.GlobalColor.red)
painter = QtGui.QPainter(pixmap)
pen = QtGui.QPen(QtCore.Qt.GlobalColor.white)
pen.setWidth(2)
painter.setPen(pen)
painter.drawLine(8, 8, 24, 24)
painter.drawLine(24, 8, 8, 24)
painter.end()
red_icon.addPixmap(pixmap)
self.closeButton.setIcon(red_icon)
def updateStyle(self):
r = "3px"
if self.dim:
fg = "#aaa"
bg = "#44a"
border = "#339"
else:
fg = "#fff"
bg = "#3f4042"
border = "#3f4042"
if self.orientation == "vertical":
self.vStyle = """DockLabel {
background-color : %s;
color : %s;
border-top-right-radius: 0px;
border-top-left-radius: %s;
border-bottom-right-radius: 0px;
border-bottom-left-radius: %s;
border-width: 0px;
border-right: 2px solid %s;
padding-top: 3px;
padding-bottom: 3px;
font-size: %s;
}""" % (
bg,
fg,
r,
r,
border,
self.fontSize,
)
self.setStyleSheet(self.vStyle)
else:
self.hStyle = """DockLabel {
background-color : %s;
color : %s;
border-top-right-radius: %s;
border-top-left-radius: %s;
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
border-width: 0px;
border-bottom: 2px solid %s;
padding-left: 3px;
padding-right: 3px;
font-size: %s;
}""" % (
bg,
fg,
r,
r,
border,
self.fontSize,
)
self.setStyleSheet(self.hStyle)
class BECDock(BECWidget, Dock):
ICON_NAME = "widgets"
USER_ACCESS = [
"_config_dict",
"element_list",
"elements",
"new",
"show",
"hide",
"show_title_bar",
"set_title",
"hide_title_bar",
"available_widgets",
"delete",
"delete_all",
"remove",
"attach",
"detach",
]
def __init__(
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
client=None,
gui_id: str | None = None,
closable: bool = True,
**kwargs,
) -> None:
if config is None:
config = DockConfig(
widget_class=self.__class__.__name__,
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
)
else:
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
label = CustomDockLabel(text=name, closable=closable)
super().__init__(
parent=parent_dock_area,
name=name,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
label=label,
**kwargs,
)
self.parent_dock_area = parent_dock_area
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
def dropEvent(self, event):
source = event.source()
old_area = source.area
self.setOrientation("horizontal", force=True)
super().dropEvent(event)
if old_area in self.orig_area.tempAreas and old_area != self.orig_area:
self.orig_area.removeTempArea(old_area)
old_area.window().deleteLater()
def float(self):
"""
Float the dock.
Overwrites the default pyqtgraph dock float.
"""
# need to check if the dock is temporary and if it is the only dock in the area
# fixes bug in pyqtgraph detaching
if self.area.temporary == True and len(self.area.docks) <= 1:
return
elif self.area.temporary == True and len(self.area.docks) > 1:
self.area.docks.pop(self.name(), None)
super().float()
else:
super().float()
@property
def elements(self) -> dict[str, BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget.object_name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(list): The widgets in the dock.
"""
return self.widgets
def hide_title_bar(self):
"""
Hide the title bar of the dock.
"""
# self.hideTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
self.label.hide()
self.labelHidden = True
def show(self):
"""
Show the dock.
"""
super().show()
self.show_title_bar()
def hide(self):
"""
Hide the dock.
"""
self.hide_title_bar()
super().hide()
def show_title_bar(self):
"""
Hide the title bar of the dock.
"""
# self.showTitleBar() #TODO pyqtgraph looks bugged ATM, doing my implementation
self.label.show()
self.labelHidden = False
def set_title(self, title: str):
"""
Set the title of the dock.
Args:
title(str): The title of the dock.
"""
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
self.setTitle(title)
def get_widgets_positions(self) -> dict:
"""
Get the positions of the widgets in the dock.
Returns:
dict: The positions of the widgets in the dock as dict -> {(row, col, rowspan, colspan):widget}
"""
return self.layout_manager.get_widgets_positions()
def available_widgets(
self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
"""
List all widgets that can be added to the dock.
Returns:
list: The list of eligible widgets.
"""
return list(widget_handler.widget_classes.keys())
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
if (docks := self.parent_dock_area.panel_list) is None:
return []
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
name: str | None = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
shift: Literal["down", "up", "left", "right"] = "down",
) -> BECWidget:
"""
Add a widget to the dock.
Args:
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
name(str): The name of the widget.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span.
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
row = self.layout.rowCount()
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
raise ValueError(f"Widget {widget} can not be added to dock.")
if isinstance(widget, str):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget, object_name=name, parent_dock=self, parent=self
),
)
else:
widget.object_name = name
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if hasattr(widget, "config"):
widget.config.gui_id = widget.gui_id
self.config.widgets[widget.object_name] = widget.config
return widget
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
"""
Move a widget to a new position in the layout.
Args:
widget(QWidget): The widget to move.
new_row(int): The new row to move the widget to.
new_col(int): The new column to move the widget to.
"""
self.layout_manager.move_widget(widget, new_row, new_col)
def attach(self):
"""
Attach the dock to the parent dock area.
"""
self.parent_dock_area.remove_temp_area(self.area)
def detach(self):
"""
Detach the dock from the parent dock area.
"""
self.float()
def remove(self):
"""
Remove the dock from the parent dock area.
"""
self.parent_dock_area.delete(self.object_name)
def delete(self, widget_name: str) -> None:
"""
Remove a widget from the dock.
Args:
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
f"Checking if gui_id was passed as widget_name."
)
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
widget = self.rpc_register.get_rpc_by_id(widget_name)
if widget is None:
logger.warning(
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
)
return
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget.object_name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
widget.deleteLater()
def delete_all(self):
"""
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget.object_name)
def cleanup(self):
"""
Clean up the dock, including all its widgets.
"""
# # FIXME Cleanup might be called twice
try:
logger.info(f"Cleaning up dock {self.name()}")
self.label.close()
self.label.deleteLater()
except Exception as e:
logger.error(f"Error while closing dock label: {e}")
# Remove the dock from the parent dock area
if self.parent_dock_area:
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
self.parent_dock_area.config.docks.pop(self.name(), None)
self.delete_all()
self.widgets.clear()
super().cleanup()
self.deleteLater()
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
self.cleanup()
super().close()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication([])
dock = BECDock(name="dock")
dock.show()
app.exec_()
sys.exit(app.exec_())

View File

@@ -1,633 +0,0 @@
from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
WidgetAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
docks_state: Optional[dict] = Field(
None, description="The state of the docks in the dock area."
)
class BECDockArea(BECWidget, QWidget):
"""
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
"""
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"_get_all_rpc",
"new",
"show",
"hide",
"panels",
"panel_list",
"delete",
"delete_all",
"remove",
"detach_dock",
"attach_all",
"save_state",
"screenshot",
"restore_state",
]
def __init__(
self,
parent: QWidget | None = None,
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
object_name: str = None,
**kwargs,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(
parent=parent,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
**kwargs,
)
self._parent = parent # TODO probably not needed
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self._instructions_visible = True
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(parent=self)
self._setup_toolbar()
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self._hook_toolbar()
self.toolbar.show_bundles(
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
)
def minimumSizeHint(self):
return QSize(800, 600)
def _setup_toolbar(self):
# Add plot menu
self.toolbar.components.add_safe(
"menu_plots",
ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME,
tooltip="Add Waveform",
filled=True,
parent=self,
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
parent=self,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
parent=self,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME,
tooltip="Add Motor Map",
filled=True,
parent=self,
),
"heatmap": MaterialIconAction(
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
),
},
),
)
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
bundle.add_action("menu_plots")
self.toolbar.add_bundle(bundle)
# Add control menu
self.toolbar.components.add_safe(
"menu_devices",
ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME,
tooltip="Add Scan Control",
filled=True,
parent=self,
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME,
tooltip="Add Device Box",
filled=True,
parent=self,
),
},
),
)
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
bundle.add_action("menu_devices")
self.toolbar.add_bundle(bundle)
# Add utils menu
self.toolbar.components.add_safe(
"menu_utils",
ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME,
tooltip="Add Scan Queue",
filled=True,
parent=self,
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME,
tooltip="Add VS Code",
filled=True,
parent=self,
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
parent=self,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
parent=self,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
parent=self,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
),
},
),
)
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
bundle.add_action("menu_utils")
self.toolbar.add_bundle(bundle)
########## Dock Actions ##########
spacer = QWidget(parent=self)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
)
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
bundle.add_action("spacer")
bundle.add_action("dark_mode")
self.toolbar.add_bundle(bundle)
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.add_safe(
"save_state",
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
)
self.toolbar.components.add_safe(
"restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
bundle.add_action("screenshot")
self.toolbar.add_bundle(bundle)
def _hook_toolbar(self):
menu_plots = self.toolbar.components.get_action("menu_plots")
menu_devices = self.toolbar.components.get_action("menu_devices")
menu_utils = self.toolbar.components.get_action("menu_utils")
menu_plots.actions["waveform"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
menu_plots.actions["scatter_waveform"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
)
menu_plots.actions["multi_waveform"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
)
menu_plots.actions["image"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Image")
)
menu_plots.actions["motor_map"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
menu_plots.actions["heatmap"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
)
# Menu Devices
menu_devices.actions["scan_control"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
menu_devices.actions["positioner_box"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
menu_utils.actions["queue"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
menu_utils.actions["status"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
menu_utils.actions["vs_code"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
menu_utils.actions["progress_bar"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
# FIXME temporarily disabled -> issue #644
menu_utils.actions["log_panel"].action.setEnabled(False)
menu_utils.actions["sbb_monitor"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
)
# Icons
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state
)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
name = pascal_to_snake(widget_name)
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
super().paintEvent(event)
if self._instructions_visible:
painter = QPainter(self)
painter.drawText(
self.rect(),
Qt.AlignCenter,
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def panels(self) -> dict[str, BECDock]:
"""
Get the docks in the dock area.
Returns:
dock_dict(dict): The docks in the dock area.
"""
return dict(self.dock_area.docks)
@panels.setter
def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
@property
def panel_list(self) -> list[BECDock]:
"""
Get the docks in the dock area.
Returns:
list: The docks in the dock area.
"""
return list(self.dock_area.docks.values())
@property
def temp_areas(self) -> list:
"""
Get the temporary areas in the dock area.
Returns:
list: The temporary areas in the dock area.
"""
return list(map(str, self.dock_area.tempAreas))
@temp_areas.setter
def temp_areas(self, value: list):
self.dock_area.tempAreas = list(map(str, value))
@SafeSlot()
def restore_state(
self, state: dict = None, missing: Literal["ignore", "error"] = "ignore", extra="bottom"
):
"""
Restore the state of the dock area. If no state is provided, the last state is restored.
Args:
state(dict): The state to restore.
missing(Literal['ignore','error']): What to do if a dock is missing.
extra(str): Extra docks that are in the dockarea but that are not mentioned in state will be added to the bottom of the dockarea, unless otherwise specified by the extra argument.
"""
if state is None:
state = self.config.docks_state
if state is None:
return
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
def save_state(self) -> dict:
"""
Save the state of the dock area.
Returns:
dict: The state of the dock area.
"""
last_state = self.dock_area.saveState()
self.config.docks_state = last_state
return last_state
@SafeSlot(popup_error=True)
def new(
self,
name: str | None = None,
widget: str | QWidget | None = None,
widget_name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
relative_to: BECDock | None = None,
closable: bool = True,
floating: bool = False,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
) -> BECDock:
"""
Add a dock to the dock area. Dock has QGridLayout as layout manager by default.
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
colspan(int): The colspan of the added widget.
Returns:
BECDock: The created dock.
"""
dock_names = [
dock.object_name for dock in self.panel_list
] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self.object_name} and id {self.gui_id}."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(
parent=self,
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
closable=closable,
)
dock.config.position = position
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1:
dock.hide_title_bar()
elif len(self.dock_area.docks) > 1:
for dock in self.dock_area.docks.values():
dock.show_title_bar()
if widget is not None:
# Check if widget name exists.
dock.new(
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
)
if (
self._instructions_visible
): # TODO still decide how initial instructions should be handled
self._instructions_visible = False
self.update()
if floating:
dock.detach()
return dock
def detach_dock(self, dock_name: str) -> BECDock:
"""
Undock a dock from the dock area.
Args:
dock_name(str): The dock to undock.
Returns:
BECDock: The undocked dock.
"""
dock = self.dock_area.docks[dock_name]
dock.detach()
return dock
@SafeSlot()
def attach_all(self):
"""
Return all floating docks to the dock area.
"""
while self.dock_area.tempAreas:
for temp_area in self.dock_area.tempAreas:
self.remove_temp_area(temp_area)
def remove_temp_area(self, area):
"""
Remove a temporary area from the dock area.
This is a patched method of pyqtgraph's removeTempArea
"""
if area not in self.dock_area.tempAreas:
# FIXME add some context for the logging, I am not sure which object is passed.
# It looks like a pyqtgraph.DockArea
logger.info(f"Attempted to remove dock_area, but was not floating.")
return
self.dock_area.tempAreas.remove(area)
area.window().close()
area.window().deleteLater()
def cleanup(self):
"""
Cleanup the dock area.
"""
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
super().cleanup()
def show(self):
"""Show all windows including floating docks."""
super().show()
for docks in self.panels.values():
if docks.window() is self:
# avoid recursion
continue
docks.window().show()
def hide(self):
"""Hide all windows including floating docks."""
super().hide()
for docks in self.panels.values():
if docks.window() is self:
# avoid recursion
continue
docks.window().hide()
def delete_all(self) -> None:
"""
Delete all docks.
"""
self.attach_all()
for dock_name in self.panels.keys():
self.delete(dock_name)
def delete(self, dock_name: str):
"""
Delete a dock by name.
Args:
dock_name(str): The name of the dock to delete.
"""
dock = self.dock_area.docks.pop(dock_name, None)
self.config.docks.pop(dock_name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {dock_name} does not exist.")
# self._broadcast_update()
def remove(self) -> None:
"""
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
parent = self.parent()
if isinstance(parent, BECMainWindow):
central_widget = parent.centralWidget()
if central_widget is self:
# Closing the parent will also close the dock area
parent.close()
return
self.close()
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
apply_theme("dark")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="DarkModeButton")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
WidgetHierarchy.print_becconnector_hierarchy_from_app()
app.exec_()
sys.exit(app.exec_())

View File

@@ -6,7 +6,7 @@ from typing import Any, Callable, Literal, Mapping, Sequence, cast
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer
from qtpy.QtCore import QByteArray, QSettings, QSize, Qt, QTimer
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
from shiboken6 import isValid
@@ -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 (
@@ -54,6 +55,7 @@ class DockAreaWidget(BECWidget, QWidget):
"widget_list",
"attach_all",
"delete_all",
"delete",
"set_layout_ratios",
"describe_layout",
"print_layout_structure",
@@ -111,6 +113,7 @@ class DockAreaWidget(BECWidget, QWidget):
)
self._root_layout.addWidget(self.dock_manager, 1)
self._install_manager_parent_guards()
################################################################################
# Dock Utility Helpers
@@ -253,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,
@@ -301,6 +352,13 @@ class DockAreaWidget(BECWidget, QWidget):
dock = CDockWidget(self.dock_manager, widget.objectName(), self)
dock.setWidget(widget)
widget_min_size = widget.minimumSize()
widget_min_hint = widget.minimumSizeHint()
dock_min_size = QSize(
max(widget_min_size.width(), widget_min_hint.width()),
max(widget_min_size.height(), widget_min_hint.height()),
)
dock.setMinimumSize(dock_min_size)
dock._dock_preferences = dict(dock_preferences or {})
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True)
@@ -323,7 +381,9 @@ class DockAreaWidget(BECWidget, QWidget):
if hasattr(widget, "widget_removed"):
widget.widget_removed.connect(on_widget_destroyed)
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
dock.setMinimumSizeHintMode(
CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidgetMinimumSize
)
dock_area_widget = None
if tab_with is not None:
if not isValid(tab_with):
@@ -346,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:
@@ -1188,8 +1249,7 @@ class DockAreaWidget(BECWidget, QWidget):
if button is not None:
button.setVisible(bool(visible))
# single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize
QTimer.singleShot(0, apply)
apply()
def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None:
"""
@@ -1227,6 +1287,7 @@ class DockAreaWidget(BECWidget, QWidget):
promote_central: bool = False,
dock_icon: QIcon | None = None,
apply_widget_icon: bool = True,
object_name: str | None = None,
**widget_kwargs,
) -> QWidget | CDockWidget | BECWidget:
"""
@@ -1254,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``.
@@ -1262,6 +1323,9 @@ class DockAreaWidget(BECWidget, QWidget):
the widget's ``ICON_NAME`` attribute is used when available.
apply_widget_icon(bool): When False, skip automatically resolving the icon from
the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly).
object_name(str | None): Optional object name to assign to the created widget.
**widget_kwargs: Additional keyword arguments passed to the widget constructor
when creating by type name.
Returns:
The widget instance by default, or the created `CDockWidget` when `return_dock` is True.
@@ -1273,7 +1337,9 @@ class DockAreaWidget(BECWidget, QWidget):
)
widget = cast(
BECWidget,
widget_handler.create_widget(widget_type=widget, parent=self, **widget_kwargs),
widget_handler.create_widget(
widget_type=widget, parent=self, object_name=object_name, **widget_kwargs
),
)
spec = self._build_creation_spec(
@@ -1295,11 +1361,7 @@ class DockAreaWidget(BECWidget, QWidget):
apply_widget_icon=apply_widget_icon,
)
def _on_name_established(_name: str) -> None:
# Defer creation so BECConnector sibling name enforcement has completed.
QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec))
widget.name_established.connect(_on_name_established)
self._create_dock_from_spec(spec)
return widget
spec = self._build_creation_spec(
@@ -1323,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):
@@ -1369,17 +1434,42 @@ class DockAreaWidget(BECWidget, QWidget):
QtAds.DockWidgetArea.RightDockWidgetArea, dock, target
)
@SafeSlot(str)
def delete(self, object_name: str) -> bool:
"""
Remove a widget from the dock area by its object name.
Args:
object_name: The object name of the widget to remove.
Returns:
bool: True if the widget was found and removed, False otherwise.
Raises:
ValueError: If no widget with the given object name is found.
Example:
>>> dock_area.delete("my_widget")
True
"""
dock_map = self.dock_map()
dock = dock_map.get(object_name)
if dock is None:
raise ValueError(f"No widget found with object name '{object_name}'.")
self._delete_dock(dock)
return True
@SafeSlot()
def delete_all(self):
"""Delete all docks and their associated widgets."""
for dock in list(self.dock_manager.dockWidgets()):
for dock in self.dock_list():
self._delete_dock(dock)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton
from qtpy.QtWidgets import QLabel, QMainWindow, QPushButton
from bec_widgets.utils.colors import apply_theme

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import os
from typing import Callable, Literal, Mapping, Sequence
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,
@@ -18,9 +19,11 @@ 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
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
@@ -29,14 +32,15 @@ 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,
get_last_profile,
is_profile_read_only,
is_quick_select,
list_profiles,
list_quick_profiles,
load_default_profile_screenshot,
load_user_profile_screenshot,
@@ -47,20 +51,17 @@ from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
profile_origin_display,
read_manifest,
restore_user_from_default,
sanitize_namespace,
set_last_profile,
set_quick_select,
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,
)
@@ -68,14 +69,14 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
@@ -86,25 +87,28 @@ 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 = [
"new",
"dock_map",
"dock_list",
"widget_map",
"widget_list",
"lock_workspace",
"workspace_is_locked",
"attach_all",
"delete_all",
"delete",
"set_layout_ratios",
"describe_layout",
"print_layout_structure",
"mode",
"mode.setter",
"list_profiles",
"save_profile",
"load_profile",
"delete_profile",
]
# Define a signal for mode changes
@@ -121,21 +125,18 @@ class AdvancedDockArea(DockAreaWidget):
instance_id: str | None = None,
auto_save_upon_exit: bool = True,
enable_profile_management: bool = True,
restore_initial_profile: bool = True,
startup_profile: StartupProfile = "restore",
**kwargs,
):
self._profile_namespace_hint = profile_namespace
self._profile_namespace_auto = auto_profile_namespace
self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET
self._instance_id = sanitize_namespace(instance_id) if instance_id else None
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._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)
@@ -155,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
@@ -170,49 +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
# Sync Developer toggle icon state after initial setup #TODO temporary disable
# dev_action = self.toolbar.components.get_action("developer_mode").action
# dev_action.setChecked(self._editable)
# 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 _fetch_initial_profile(self):
# Restore last-used profile if available; otherwise fall back to combo selection
@staticmethod
def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile:
"""
Normalize startup profile values.
"""
if startup_profile == "":
return None
return startup_profile
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
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:
if self._profile_exists("general", namespace):
init_profile = "general"
if init_profile:
# Defer initial load to the event loop so child widgets exist before state restore.
QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile))
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 after construction when the event loop is running."""
"""Load the initial profile."""
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:
@@ -231,20 +270,46 @@ class AdvancedDockArea(DockAreaWidget):
movable: bool = True,
start_floating: bool = False,
where: Literal["left", "right", "top", "bottom"] | None = None,
on_close: Callable[[CDockWidget, QWidget], None] | None = None,
tab_with: CDockWidget | QWidget | str | None = None,
relative_to: CDockWidget | QWidget | str | None = None,
return_dock: bool = False,
show_title_bar: bool | None = None,
title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None,
show_settings_action: bool | None = None,
promote_central: bool = False,
object_name: str | None = None,
**widget_kwargs,
) -> QWidget | CDockWidget | BECWidget:
) -> QWidget | BECWidget:
"""
Override the base helper so dock settings are available by default.
Create a new widget (or reuse an instance) and add it as a dock.
The flag remains user-configurable (pass ``False`` to hide the action).
Args:
widget(QWidget | str): Instance or registered widget type string.
closable(bool): Whether the dock is closable.
floatable(bool): Whether the dock is floatable.
movable(bool): Whether the dock is movable.
start_floating(bool): Whether to start the dock floating.
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
``relative_to`` is provided without an explicit value).
tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside.
relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor.
When supplied and ``where`` is ``None``, the new dock inherits the
anchor's current dock area.
show_title_bar(bool | None): Explicitly show or hide the dock area's title bar.
title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should
remain visible. Provide a mapping of button names (``"float"``,
``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans,
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 `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.
**widget_kwargs: Additional keyword arguments passed to the widget constructor
when creating by type name.
Returns:
BECWidget: The created or reused widget instance.
"""
if show_settings_action is None:
show_settings_action = True
@@ -255,14 +320,13 @@ class AdvancedDockArea(DockAreaWidget):
movable=movable,
start_floating=start_floating,
where=where,
on_close=on_close,
tab_with=tab_with,
relative_to=relative_to,
return_dock=return_dock,
show_title_bar=show_title_bar,
title_buttons=title_buttons,
show_settings_action=show_settings_action,
promote_central=promote_central,
object_name=object_name,
**widget_kwargs,
)
@@ -279,7 +343,7 @@ class AdvancedDockArea(DockAreaWidget):
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
PLOT_ACTIONS = {
plot_actions = {
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
"scatter_waveform": (
ScatterWaveform.ICON_NAME,
@@ -291,7 +355,7 @@ class AdvancedDockArea(DockAreaWidget):
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
}
DEVICE_ACTIONS = {
device_actions = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
"positioner_box_2D": (
@@ -300,7 +364,7 @@ class AdvancedDockArea(DockAreaWidget):
"PositionerBox2D",
),
}
UTIL_ACTIONS = {
util_actions = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
@@ -308,8 +372,8 @@ class AdvancedDockArea(DockAreaWidget):
"Add Circular ProgressBar",
"RingProgressBar",
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"),
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
@@ -332,9 +396,9 @@ class AdvancedDockArea(DockAreaWidget):
b.add_action(key)
self.toolbar.add_bundle(b)
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
_build_menu("menu_plots", "Add Plot ", plot_actions)
_build_menu("menu_devices", "Add Device Control ", device_actions)
_build_menu("menu_utils", "Add Utils ", util_actions)
# Create flat toolbar bundles for each widget type
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
@@ -358,14 +422,14 @@ class AdvancedDockArea(DockAreaWidget):
self.toolbar.add_bundle(bundle)
_build_flat_bundles("plots", PLOT_ACTIONS)
_build_flat_bundles("devices", DEVICE_ACTIONS)
_build_flat_bundles("utils", UTIL_ACTIONS)
_build_flat_bundles("plots", plot_actions)
_build_flat_bundles("devices", device_actions)
_build_flat_bundles("utils", util_actions)
# Workspace
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
spacer = QWidget(parent=self.toolbar.components.toolbar)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
spacer_bundle.add_action("spacer")
self.toolbar.add_bundle(spacer_bundle)
@@ -404,16 +468,15 @@ class AdvancedDockArea(DockAreaWidget):
bda.add_action("attach_all")
bda.add_action("screenshot")
bda.add_action("dark_mode")
# bda.add_action("developer_mode") #TODO temporary disable
self.toolbar.add_bundle(bda)
self._apply_toolbar_layout()
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = {
"menu_plots": PLOT_ACTIONS,
"menu_devices": DEVICE_ACTIONS,
"menu_utils": UTIL_ACTIONS,
"menu_plots": plot_actions,
"menu_devices": device_actions,
"menu_utils": util_actions,
}
def _hook_toolbar(self):
@@ -433,10 +496,7 @@ class AdvancedDockArea(DockAreaWidget):
elif key == "bec_shell":
act.triggered.connect(
lambda _, t=widget_type: self.new(
widget=t,
closable=True,
startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}",
show_settings_action=True,
widget=t, closable=True, show_settings_action=False
)
)
else:
@@ -463,7 +523,7 @@ class AdvancedDockArea(DockAreaWidget):
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self.workspace_is_locked = not editable
self._editable = editable
if self._profile_management_enabled:
@@ -477,7 +537,7 @@ class AdvancedDockArea(DockAreaWidget):
# Workspace Management
################################################################################
@SafeProperty(bool)
def lock_workspace(self) -> bool:
def workspace_is_locked(self) -> bool:
"""
Get or set the lock state of the workspace.
@@ -486,8 +546,8 @@ class AdvancedDockArea(DockAreaWidget):
"""
return self._locked
@lock_workspace.setter
def lock_workspace(self, value: bool):
@workspace_is_locked.setter
def workspace_is_locked(self, value: bool):
"""
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
@@ -527,7 +587,7 @@ class AdvancedDockArea(DockAreaWidget):
if not candidate:
candidate = self.__class__.__name__
resolved = sanitize_namespace(candidate) if candidate else None
resolved = slugify.slugify(candidate, separator="_") if candidate else None
if not resolved:
resolved = "general"
self._profile_namespace_resolved = resolved # type: ignore[assignment]
@@ -538,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)
@@ -569,8 +622,89 @@ class AdvancedDockArea(DockAreaWidget):
logger.info(f"Workspace snapshot written to settings: {settings.fileName()}")
def _write_profile_settings(
self,
name: str,
namespace: str | None,
*,
write_default: bool = True,
write_user: bool = True,
save_preview: bool = True,
) -> None:
"""
Write profile settings to default and/or user settings files.
Args:
name: The profile name.
namespace: The profile namespace.
write_default: Whether to write to the default settings file.
write_user: Whether to write to the user settings file.
save_preview: Whether to save a screenshot preview.
"""
if write_default:
ds = open_default_settings(name, namespace=namespace)
self._write_snapshot_to_settings(ds, save_preview=save_preview)
if not ds.value(SETTINGS_KEYS["created_at"], ""):
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
ds.setValue(SETTINGS_KEYS["is_quick_select"], True)
if write_user:
us = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(us, save_preview=save_preview)
if not us.value(SETTINGS_KEYS["created_at"], ""):
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
us.setValue(SETTINGS_KEYS["is_quick_select"], True)
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
"""
Finalize a profile change by updating state and refreshing the UI.
Args:
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]:
"""
List available workspace profiles in the current namespace.
Returns:
list[str]: List of profile names.
"""
namespace = self.profile_namespace
return list_profiles(namespace)
@SafeSlot(str)
def save_profile(self, name: str | None = None):
@rpc_timeout(None)
def save_profile(
self,
name: str | None = None,
*,
show_dialog: bool = False,
quick_select: bool | None = None,
):
"""
Save the current workspace profile.
@@ -582,86 +716,124 @@ class AdvancedDockArea(DockAreaWidget):
Read-only bundled profiles cannot be overwritten.
Args:
name (str | None): The name of the profile to save. If None, prompts the user.
name (str | None): The name of the profile to save. If None and show_dialog is True,
prompts the user.
show_dialog (bool): If True, shows the SaveProfileDialog for user interaction.
If False (default), saves directly without user interaction (useful for CLI usage).
quick_select (bool | None): Whether to include the profile in quick selection.
If None (default), uses the existing value or True for new profiles.
Only used when show_dialog is False; otherwise the dialog provides the value.
"""
namespace = self.profile_namespace
current_profile = getattr(self, "_current_profile_name", "") or ""
def _profile_exists(profile_name: str) -> bool:
return profile_origin(profile_name, namespace=namespace) != "unknown"
initial_name = name or ""
quickselect_default = is_quick_select(name, namespace=namespace) if name else False
# Determine final values either from dialog or directly
if show_dialog:
initial_name = name or ""
quickselect_default = is_quick_select(name, namespace=namespace) if name else True
current_profile = getattr(self, "_current_profile_name", "") or ""
dialog = SaveProfileDialog(
self,
current_name=initial_name,
current_profile_name=current_profile,
name_exists=_profile_exists,
profile_origin=lambda n: profile_origin(n, namespace=namespace),
origin_label=lambda n: profile_origin_display(n, namespace=namespace),
quick_select_checked=quickselect_default,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
dialog = SaveProfileDialog(
self,
current_name=initial_name,
current_profile_name=current_profile,
name_exists=_profile_exists,
profile_origin=lambda n: profile_origin(n, namespace=namespace),
origin_label=lambda n: profile_origin_display(n, namespace=namespace),
quick_select_checked=quickselect_default,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
name = dialog.get_profile_name()
quickselect = dialog.is_quick_select()
overwrite_existing = dialog.overwrite_existing
else:
# CLI / programmatic usage - no dialog
if not name:
logger.warning("save_profile called without name and show_dialog=False")
return
# Determine quick_select value
if quick_select is None:
# Use existing value if profile exists, otherwise default to True
quickselect = (
is_quick_select(name, namespace=namespace) if _profile_exists(name) else True
)
else:
quickselect = quick_select
# For programmatic saves, check if profile is read-only
origin = profile_origin(name, namespace=namespace)
if origin in {"module", "plugin"}:
logger.warning(f"Cannot save to read-only profile '{name}' (origin: {origin})")
return
# Overwrite existing settings profile when saving programmatically
overwrite_existing = origin == "settings"
name = dialog.get_profile_name()
quickselect = dialog.is_quick_select()
origin_before_save = profile_origin(name, namespace=namespace)
overwrite_default = dialog.overwrite_existing and origin_before_save == "settings"
# Display saving placeholder
overwrite_default = overwrite_existing and origin_before_save == "settings"
# Display saving placeholder in toolbar
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
workspace_combo.blockSignals(True)
workspace_combo.insertItem(0, f"{name}-saving")
workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False)
# Create or update default copy controlled by overwrite flag
# Write to default and/or user settings
should_write_default = overwrite_default or not any(
os.path.exists(path) for path in default_profile_candidates(name, namespace)
)
if should_write_default:
ds = open_default_settings(name, namespace=namespace)
self._write_snapshot_to_settings(ds)
if not ds.value(SETTINGS_KEYS["created_at"], ""):
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
# Ensure new profiles are not quick-select by default
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
ds.setValue(SETTINGS_KEYS["is_quick_select"], False)
self._write_profile_settings(
name, namespace, write_default=should_write_default, write_user=True
)
# Always (over)write the user copy
us = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(us)
if not us.value(SETTINGS_KEYS["created_at"], ""):
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
# Ensure new profiles are not quick-select by default (only if missing)
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
us.setValue(SETTINGS_KEYS["is_quick_select"], False)
# set quick select
if quickselect:
set_quick_select(name, quickselect, namespace=namespace)
set_quick_select(name, quickselect, namespace=namespace)
self._refresh_workspace_list()
if current_profile and current_profile != name and not dialog.overwrite_existing:
if current_profile and current_profile != name and not overwrite_existing:
self._pending_autosave_skip = (current_profile, name)
else:
self._pending_autosave_skip = None
workspace_combo.setCurrentText(name)
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)
self._finalize_profile_change(name, namespace)
@SafeSlot()
@SafeSlot(str)
def save_profile_dialog(self, name: str | None = None):
"""
Save the current workspace profile with a dialog prompt.
This is a convenience method for UI usage (toolbar, dialogs) that
always shows the SaveProfileDialog. For programmatic/CLI usage,
use save_profile() directly.
Args:
name (str | None): Optional initial name to populate in the dialog.
"""
self.save_profile(name, show_dialog=True)
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
Before switching, persist the current profile to the user copy.
Prefer loading the user copy; fall back to the default copy.
Args:
name (str | None): The name of the profile to load. If None, prompts the user.
"""
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:"
@@ -685,9 +857,14 @@ class AdvancedDockArea(DockAreaWidget):
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
settings = open_default_settings(name, namespace=namespace)
if settings is None:
QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.")
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
self.delete_all()
self.save_profile(name, show_dialog=False, quick_select=True)
return
# Clear existing docks and remove all widgets
self.delete_all()
# Rebuild widgets and restore states
for item in read_manifest(settings):
obj_name = item["object_name"]
@@ -716,11 +893,7 @@ class AdvancedDockArea(DockAreaWidget):
self.state_manager.load_state(settings=settings)
self._set_editable(self._editable)
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)
self._finalize_profile_change(name, namespace)
@SafeSlot()
@SafeSlot(str)
@@ -754,37 +927,82 @@ class AdvancedDockArea(DockAreaWidget):
self.load_profile(target)
@SafeSlot()
def delete_profile(self):
def delete_profile(self, name: str | None = None, show_dialog: bool = False) -> bool:
"""
Delete the currently selected workspace profile file and refresh the combo list.
Delete a workspace profile.
Args:
name: The name of the profile to delete. If None, uses the currently
selected profile from the toolbar combo box (for UI usage).
show_dialog: If True, show confirmation dialog before deletion.
Defaults to False for CLI/programmatic usage.
Returns:
bool: True if the profile was deleted, False otherwise.
Raises:
ValueError: If the profile is read-only or doesn't exist (when show_dialog=False).
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
name = combo.currentText()
# Resolve profile name
if name is None:
combo = self.toolbar.components.get_action("workspace_combo").widget
name = combo.currentText()
if not name:
return
# Protect bundled/module/plugin profiles from deletion
if is_profile_read_only(name, namespace=self.profile_namespace):
QMessageBox.information(
self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted."
)
return
# Confirm deletion for regular profiles
reply = QMessageBox.question(
self,
"Delete Profile",
f"Are you sure you want to delete the profile '{name}'?\n\n"
f"This action cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
if show_dialog:
return False
raise ValueError("No profile name provided.")
namespace = self.profile_namespace
delete_profile_files(name, namespace=namespace)
# Check if profile is read-only
if is_profile_read_only(name, namespace=namespace):
if show_dialog:
QMessageBox.information(
self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted."
)
return False
raise ValueError(f"Profile '{name}' is read-only and cannot be deleted.")
# Confirm deletion if dialog is enabled
if show_dialog:
reply = QMessageBox.question(
self,
"Delete Profile",
f"Are you sure you want to delete the profile '{name}'?\n\n"
f"This action cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return False
# Perform deletion
try:
removed = delete_profile_files(name, namespace=namespace)
except OSError as exc:
if show_dialog:
QMessageBox.warning(
self, "Delete Profile", f"Failed to delete profile '{name}': {exc}"
)
return False
raise ValueError(f"Failed to delete profile '{name}': {exc}") from exc
if not removed:
if show_dialog:
QMessageBox.information(
self, "Delete Profile", "No writable profile files were found to delete."
)
return False
raise ValueError(f"No writable profile files found for '{name}'.")
# Clear current profile if it was deleted
if getattr(self, "_current_profile_name", None) == name:
self._current_profile_name = None
# Refresh the workspace list
self._refresh_workspace_list()
return True
def _refresh_workspace_list(self):
"""
@@ -792,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("")
@@ -915,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)
@@ -923,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.
@@ -946,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}
@@ -10,20 +10,21 @@ Policy:
from __future__ import annotations
import os
import re
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Literal
import slugify
from bec_lib import bec_logger
from bec_lib.client import BECClient
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from pydantic import BaseModel, Field
from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt
from qtpy.QtCore import QByteArray, QDateTime, QSettings, QTimeZone
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.name_utils import sanitize_namespace
from bec_widgets.widgets.containers.qt_ads import CDockWidget
logger = bec_logger.logger
@@ -35,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)
@@ -114,35 +115,16 @@ 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
def sanitize_namespace(namespace: str | None) -> str | None:
"""
Clean user-provided namespace labels for filesystem compatibility.
Args:
namespace (str | None): Arbitrary namespace identifier supplied by the caller.
Returns:
str | None: Sanitized namespace containing only safe characters, or ``None``
when the input is empty.
"""
if not namespace:
return None
ns = namespace.strip()
if not ns:
return None
return re.sub(r"[^0-9A-Za-z._-]+", "_", ns)
def _profiles_dir(segment: str, namespace: str | None) -> str:
"""
Build (and ensure) the directory that holds profiles for a namespace segment.
@@ -155,8 +137,8 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
str: Absolute directory path for the requested segment/namespace pair.
"""
base = os.path.join(_settings_profiles_root(), segment)
ns = sanitize_namespace(namespace)
path = os.path.join(base, ns) if ns else base
ns = slugify.slugify(namespace, separator="_") if namespace else None
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
os.makedirs(path, exist_ok=True)
return path
@@ -172,7 +154,7 @@ def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
Returns:
list[str]: Ordered list of candidate user profile paths (.ini files).
"""
ns = sanitize_namespace(namespace)
ns = slugify.slugify(namespace, separator="_") if namespace else None
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
if not ns:
return [primary]
@@ -191,7 +173,7 @@ def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
Returns:
list[str]: Ordered list of candidate default profile paths (.ini files).
"""
ns = sanitize_namespace(namespace)
ns = slugify.slugify(namespace, separator="_") if namespace else None
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
if not ns:
return [primary]
@@ -470,7 +452,7 @@ def list_profiles(namespace: str | None = None) -> list[str]:
Returns:
list[str]: Sorted unique profile names.
"""
ns = sanitize_namespace(namespace)
ns = slugify.slugify(namespace, separator="_") if namespace else None
def _collect_from(directory: str) -> set[str]:
if not os.path.isdir(directory):
@@ -571,11 +553,11 @@ def _last_profile_key(namespace: str | None, instance: str | None = None) -> str
Returns:
str: Scoped key string.
"""
ns = sanitize_namespace(namespace)
ns = slugify.slugify(namespace, separator="_") if namespace else None
key = SETTINGS_KEYS["last_profile"]
if ns:
key = f"{key}/{ns}"
inst = sanitize_namespace(instance) if instance else ""
inst = slugify.slugify(instance, separator="_") if instance else ""
if inst:
key = f"{key}@{inst}"
return key
@@ -645,7 +627,7 @@ def now_iso_utc() -> str:
Returns:
str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``).
"""
return QDateTime.currentDateTimeUtc().toString(Qt.ISODate)
return QDateTime.currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ssZ")
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
@@ -861,7 +843,9 @@ def _file_modified_iso(path: str) -> str:
"""
try:
mtime = os.path.getmtime(path)
return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate)
return QDateTime.fromSecsSinceEpoch(int(mtime), QTimeZone.utc()).toString(
"yyyy-MM-ddTHH:mm:ssZ"
)
except Exception:
return now_iso_utc()

View File

@@ -28,8 +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 (
delete_profile_files,
from bec_widgets.widgets.containers.dock_area.profile_utils import (
get_profile_info,
is_quick_select,
list_profiles,
@@ -330,8 +329,8 @@ class WorkSpaceManager(BECWidget, QWidget):
)
return
self.target_widget.save_profile()
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
self.target_widget.save_profile_dialog()
# 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)
@@ -341,55 +340,35 @@ class WorkSpaceManager(BECWidget, QWidget):
@SafeSlot(str)
def delete_profile(self, profile_name: str):
info = get_profile_info(profile_name, namespace=self.profile_namespace)
if info.is_read_only:
QMessageBox.information(
self, "Delete Profile", "This profile is read-only and cannot be deleted."
)
return
"""
Delete a profile by delegating to the target widget's delete_profile method.
reply = QMessageBox.question(
self,
"Delete Profile",
(
f"Delete the profile '{profile_name}'?\n\n"
"This will remove both the user and default copies."
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
Args:
profile_name: The name of the profile to delete.
"""
if self.target_widget is None or not hasattr(self.target_widget, "delete_profile"):
QMessageBox.warning(
self, "Delete Profile", "No target widget available for profile deletion."
)
return
try:
removed = delete_profile_files(profile_name, namespace=self.profile_namespace)
except OSError as exc:
QMessageBox.warning(
self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}"
)
return
result = self.target_widget.delete_profile(profile_name, show_dialog=True)
except ValueError:
# Error was already handled by target widget's dialog
result = False
if not removed:
QMessageBox.information(
self, "Delete Profile", "No writable profile files were found to delete."
)
return
if self.target_widget is not None:
if getattr(self.target_widget, "_current_profile_name", None) == profile_name:
self.target_widget._current_profile_name = None
if hasattr(self.target_widget, "_refresh_workspace_list"):
self.target_widget._refresh_workspace_list()
self.render_table()
remaining_profiles = list_profiles(namespace=self.profile_namespace)
if remaining_profiles:
next_profile = remaining_profiles[0]
self._select_by_name(next_profile)
self._show_profile_details(next_profile)
else:
self.profile_details_tree.clear()
self.screenshot_label.setPixmap(QPixmap())
if result:
# Refresh our table and select next profile
self.render_table()
remaining_profiles = list_profiles(namespace=self.profile_namespace)
if remaining_profiles:
next_profile = remaining_profiles[0]
self._select_by_name(next_profile)
self._show_profile_details(next_profile)
else:
self.profile_details_tree.clear()
self.screenshot_label.setPixmap(QPixmap())
def resizeEvent(self, event):
super().resizeEvent(event)
@@ -402,7 +381,7 @@ class WorkSpaceManager(BECWidget, QWidget):
scaled = pm.scaled(
self.screenshot_label.width() or 800,
self.screenshot_label.height() or 450,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.screenshot_label.setPixmap(scaled)

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):
@@ -147,8 +179,8 @@ class WorkspaceConnection(BundleConnection):
self.bundle_name = "workspace"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "lock_workspace"):
raise AttributeError("Target widget must implement 'lock_workspace'.")
if not hasattr(self.target_widget, "workspace_is_locked"):
raise AttributeError("Target widget must implement 'workspace_is_locked'.")
self._connected = False
def connect(self):
@@ -156,7 +188,7 @@ class WorkspaceConnection(BundleConnection):
# Connect the action to the target widget's method
save_action = self.components.get_action("save_workspace").action
if save_action.isVisible():
save_action.triggered.connect(self.target_widget.save_profile)
save_action.triggered.connect(self.target_widget.save_profile_dialog)
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
self.target_widget.load_profile
@@ -176,7 +208,7 @@ class WorkspaceConnection(BundleConnection):
# Disconnect the action from the target widget's method
save_action = self.components.get_action("save_workspace").action
if save_action.isVisible():
save_action.triggered.disconnect(self.target_widget.save_profile)
save_action.triggered.disconnect(self.target_widget.save_profile_dialog)
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
self.target_widget.load_profile
)

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

@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree = QTreeView(parent=self)
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model = QFileSystemModel(parent=self)
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model = QSortFilterProxyModel(parent=self)
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)

View File

@@ -1,27 +1,83 @@
import sys
from qtpy import QtGui, QtWidgets
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QVBoxLayout,
QWidget,
)
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
super().__init__(
None,
Qt.WindowType.ToolTip
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
layout.setContentsMargins(14, 14, 14, 14)
self._card = QFrame(self)
self._card.setObjectName("WidgetTooltipCard")
card_layout = QVBoxLayout(self._card)
card_layout.setContentsMargins(12, 10, 12, 10)
card_layout.addWidget(self.content)
shadow = QtWidgets.QGraphicsDropShadowEffect(self._card)
shadow.setBlurRadius(18)
shadow.setOffset(0, 2)
shadow.setColor(QtGui.QColor(0, 0, 0, 140))
self._card.setGraphicsEffect(shadow)
layout.addWidget(self._card)
self.apply_theme()
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def apply_theme(self) -> None:
palette = QApplication.palette()
base = palette.color(QtGui.QPalette.ColorRole.Base)
text = palette.color(QtGui.QPalette.ColorRole.Text)
border = palette.color(QtGui.QPalette.ColorRole.Mid)
background = QtGui.QColor(base)
background.setAlpha(242)
self._card.setStyleSheet(f"""
QFrame#WidgetTooltipCard {{
background: {background.name(QtGui.QColor.NameFormat.HexArgb)};
border: 1px solid {border.name()};
border-radius: 12px;
}}
QFrame#WidgetTooltipCard QLabel {{
color: {text.name()};
background: transparent;
}}
""")
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
"""
Show the tooltip above a global position, adjusting to stay within screen bounds.
Args:
global_pos(QPoint): The global position to show above.
offset(int, optional): The vertical offset from the global position. Defaults to 8 pixels.
"""
self.apply_theme()
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
@@ -30,11 +86,43 @@ class WidgetTooltip(QWidget):
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
self._navigate_screen_coordinates(screen_geo, geom, x, y)
def show_near(self, global_pos: QPoint, offset: QPoint | None = None) -> None:
"""
Show the tooltip near a global position, adjusting to stay within screen bounds.
By default, it will try to show below and to the right of the position,
but if that would cause it to go off-screen, it will flip to the other side.
Args:
global_pos(QPoint): The global position to show near.
offset(QPoint, optional): The offset from the global position. Defaults to QPoint(12, 16).
"""
self.apply_theme()
self.adjustSize()
offset = offset or QPoint(12, 16)
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() + offset.x()
y = global_pos.y() + offset.y()
if x + geom.width() > screen_geo.right():
x = global_pos.x() - geom.width() - abs(offset.x())
if y + geom.height() > screen_geo.bottom():
y = global_pos.y() - geom.height() - abs(offset.y())
self._navigate_screen_coordinates(screen_geo, geom, x, y)
def _navigate_screen_coordinates(self, screen_geo, geom, x, y):
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
self.raise_()
class HoverWidget(QWidget):

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,13 +54,16 @@ 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)
# Notification Centre overlay
self.notification_centre = NotificationCentre(parent=self) # Notification layer
self.notification_broker = BECNotificationBroker()
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()
@@ -188,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
)
@@ -253,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):
@@ -274,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()
@@ -311,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()
@@ -394,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

@@ -1,3 +0,0 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]

View File

@@ -7,16 +7,16 @@ import os
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
DeviceUpdateUIComponents,
PositionerBoxBase,
)
logger = bec_logger.logger
@@ -63,9 +63,16 @@ class PositionerBox(PositionerBoxBase):
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.ui)
self.main_layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
ui_min_size = self.ui.minimumSize()
ui_min_hint = self.ui.minimumSizeHint()
self.setMinimumSize(
max(ui_min_size.width(), ui_min_hint.width()),
max(ui_min_size.height(), ui_min_hint.height()),
)
# fix the size of the device box
db = self.ui.device_box
@@ -108,8 +115,6 @@ class PositionerBox(PositionerBoxBase):
return
old_device = self._device
self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value)
@SafeProperty(bool)

View File

@@ -15,9 +15,9 @@ from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
DeviceUpdateUIComponents,
PositionerBoxBase,
)
logger = bec_logger.logger
@@ -96,9 +96,9 @@ class PositionerBox2D(PositionerBoxBase):
def connect_ui(self):
"""Connect the UI components to signals, data, or routines"""
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.ui)
self.main_layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0)
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
ui = self._device_ui_components_hv(device_id)
@@ -200,7 +200,6 @@ class PositionerBox2D(PositionerBoxBase):
return
old_device = self._device_hor
self._device_hor = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_hor.emit(old_device, value)
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
@@ -220,7 +219,6 @@ class PositionerBox2D(PositionerBoxBase):
return
old_device = self._device_ver
self._device_ver = value
self.label = f"{self._device_hor}, {self._device_ver}"
self.device_changed_ver.emit(old_device, value)
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)

View File

@@ -14,10 +14,10 @@ from qtpy.QtWidgets import (
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
@@ -43,7 +43,7 @@ class DeviceUpdateUIComponents(TypedDict):
units: QLabel
class PositionerBoxBase(BECWidget, CompactPopupWidget):
class PositionerBoxBase(BECWidget, QWidget):
"""Contains some core logic for positioner box widgets"""
current_path = ""
@@ -57,7 +57,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
super().__init__(parent=parent, **kwargs)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self._dialog = None
self.get_bec_shortcuts()
@@ -123,7 +126,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(
@@ -173,11 +176,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
if is_moving:
spinner.start()
spinner.setToolTip("Device is moving")
self.set_global_state("warning")
else:
spinner.stop()
spinner.setToolTip("Device is idle")
self.set_global_state("success")
else:
spinner.setVisible(False)
@@ -196,9 +197,8 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
position_indicator.set_value(pos)
def _update_limits_ui(
self, limits: tuple[float, float], position_indicator, setpoint_validator
):
@staticmethod
def _update_limits_ui(limits: tuple[float, float], position_indicator, setpoint_validator):
if limits is not None and limits[0] != limits[1]:
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
setpoint_validator.setRange(limits[0], limits[1])
@@ -223,8 +223,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toogle enable/disable on available buttons
@staticmethod
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toggle enable/disable on available buttons
Args:
enable (bool): Enable buttons

View File

@@ -1,6 +1,8 @@
import os
from bec_lib.device import Positioner
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
@@ -22,7 +24,82 @@ class PositionerControlLine(PositionerBox):
device (Positioner): The device to control.
"""
self.current_path = os.path.dirname(__file__)
self._indicator_switch_width = 0
self._horizontal_indicator_width = 0
self._vertical_indicator_width = 15
self._indicator_thickness = 10
self._indicator_is_horizontal = False
self._line_height = self.dimensions[0]
super().__init__(parent=parent, device=device, *args, **kwargs)
self._configure_line_layout()
self._update_indicator_orientation()
def _configure_line_layout(self):
device_box = self.ui.device_box
indicator = self.ui.position_indicator
self.main_layout.setAlignment(Qt.AlignmentFlag(0))
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
device_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._line_height = max(
self.dimensions[0],
self.ui.minimumSizeHint().height(),
self.ui.sizeHint().height(),
device_box.minimumSizeHint().height(),
device_box.sizeHint().height(),
)
device_box.setFixedHeight(self._line_height)
device_box.setMinimumWidth(self.dimensions[1])
device_box.setMaximumWidth(16777215)
self.setFixedHeight(self._line_height)
self.setMinimumWidth(self.dimensions[1])
self.ui.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.ui.verticalLayout.setSpacing(0)
self.ui.readback.setMaximumWidth(16777215)
self.ui.setpoint.setMaximumWidth(16777215)
self.ui.step_size.setMaximumWidth(16777215)
indicator_hint = indicator.minimumSizeHint()
step_hint = self.ui.step_size.sizeHint()
self._indicator_thickness = max(indicator_hint.height(), 10)
self._vertical_indicator_width = max(indicator.minimumWidth(), 15)
self._horizontal_indicator_width = max(90, step_hint.width())
base_width = max(device_box.minimumSizeHint().width(), self.dimensions[1])
self._indicator_switch_width = (
base_width - self._vertical_indicator_width + self._horizontal_indicator_width
)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_indicator_orientation()
def _update_indicator_orientation(self):
if not hasattr(self, "ui"):
return
indicator = self.ui.position_indicator
available_width = self.ui.device_box.width() or self.width() or self.dimensions[1]
should_use_horizontal = available_width >= self._indicator_switch_width
if should_use_horizontal == self._indicator_is_horizontal:
return
self._indicator_is_horizontal = should_use_horizontal
indicator.vertical = not should_use_horizontal
if should_use_horizontal:
indicator.setMinimumSize(self._horizontal_indicator_width, self._indicator_thickness)
indicator.setMaximumHeight(self._indicator_thickness)
indicator.setMaximumWidth(16777215)
indicator.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
else:
indicator.setMinimumSize(self._vertical_indicator_width, self._indicator_thickness)
indicator.setMaximumSize(self._vertical_indicator_width, 16777215)
indicator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
indicator.updateGeometry()
if __name__ == "__main__": # pragma: no cover

View File

@@ -2,12 +2,18 @@
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>612</width>
<height>91</height>
<width>592</width>
<height>76</height>
</rect>
</property>
<property name="minimumSize">
@@ -26,8 +32,29 @@
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QGroupBox" name="device_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Device Name</string>
</property>
@@ -227,12 +254,12 @@
<customwidgets>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<extends></extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<extends></extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>

View File

@@ -27,30 +27,13 @@ class PositionerGroupBox(QGroupBox):
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
self.widget = PositionerBox(self, dev_name)
self.widget.compact_view = True
self.widget.expand_popup = False
self.layout().addWidget(self.widget)
self.widget.position_update.connect(self._on_position_update)
self.widget.expand.connect(self._on_expand)
self.setTitle(self.device_name)
self.widget.force_update_readback()
def _on_expand(self, expand):
if expand:
self.setTitle("")
self.setFlat(True)
else:
self.setTitle(self.device_name)
self.setFlat(False)
def _on_position_update(self, pos: float):
self.position_update.emit(pos)
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
self.widget.label = f"{pos:.{precision}f}"
def close(self):
self.widget.close()

View File

@@ -32,6 +32,7 @@ class DeviceInputConfig(ConnectionConfig):
default: str | None = None
arg_name: str | None = None
apply_filter: bool = True
signal_class_filter: list[str] = []
@field_validator("device_filter")
@classmethod
@@ -125,11 +126,13 @@ class DeviceInputBase(BECWidget):
current_device = WidgetIO.get_value(widget=self, as_string=True)
self.config.device_filter = self.device_filter
self.config.readout_filter = self.readout_filter
self.config.signal_class_filter = self.signal_class_filter
if self.apply_filter is False:
return
all_dev = self.dev.enabled_devices
devs = self._filter_devices_by_signal_class(all_dev)
# Filter based on device class
devs = [dev for dev in all_dev if self._check_device_filter(dev)]
devs = [dev for dev in devs if self._check_device_filter(dev)]
# Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs]
@@ -190,6 +193,27 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value
self.update_devices_from_filters()
@SafeProperty("QStringList")
def signal_class_filter(self) -> list[str]:
"""
Get the signal class filter for devices.
Returns:
list[str]: List of signal class names used for filtering devices.
"""
return self.config.signal_class_filter
@signal_class_filter.setter
def signal_class_filter(self, value: list[str] | None):
"""
Set the signal class filter and update the device list.
Args:
value (list[str] | None): List of signal class names to filter by.
"""
self.config.signal_class_filter = value or []
self.update_devices_from_filters()
@SafeProperty(bool)
def filter_to_device(self):
"""Include devices in filters."""
@@ -379,6 +403,20 @@ class DeviceInputBase(BECWidget):
"""
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
def _filter_devices_by_signal_class(
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
"""Filter devices by signal class, if a signal class filter is set."""
if not self.config.signal_class_filter:
return devices
if not self.client or not hasattr(self.client, "device_manager"):
return []
signals = FilterIO.update_with_signal_class(
widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client
)
allowed_devices = {device_name for device_name, _, _ in signals}
return [dev for dev in devices if dev.name in allowed_devices]
def _check_readout_filter(
self, device: Device | BECSignal | ComputedSignal | Positioner
) -> bool:

View File

@@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -17,6 +17,8 @@ class DeviceSignalInputBaseConfig(ConnectionConfig):
"""Configuration class for DeviceSignalInputBase."""
signal_filter: str | list[str] | None = None
signal_class_filter: list[str] | None = None
ndim_filter: int | list[int] | None = None
default: str | None = None
arg_name: str | None = None
device: str | None = None
@@ -264,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,6 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
@@ -27,12 +26,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown.
"""
USER_ACCESS = ["set_device", "devices"]
ICON_NAME = "list_alt"
PLUGIN = True
RPC = False
device_selected = Signal(str)
device_reset = Signal()
@@ -51,6 +50,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
available_devices: list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
signal_class_filter: list[str] | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -63,6 +63,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._is_valid_input = False
self._accent_colors = get_accent_colors()
self._set_first_element_as_empty = False
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
@@ -85,6 +86,10 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
# Device filter default is None
if device_filter is not None:
self.set_device_filter(device_filter)
if signal_class_filter is not None:
self.signal_class_filter = signal_class_filter
# Set default device if passed
if default is not None:
self.set_device(default)
@@ -181,21 +186,70 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
@property
def is_valid_input(self) -> bool:
"""Whether the current text represents a valid device selection."""
return self._is_valid_input
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
widget.setWindowTitle("DeviceComboBox demo")
layout = QVBoxLayout(widget)
layout.addWidget(QLabel("Device filter controls"))
controls = QHBoxLayout()
layout.addLayout(controls)
class_input = QLineEdit()
class_input.setPlaceholderText("signal_class_filter (comma-separated), e.g. AsyncSignal")
controls.addWidget(class_input)
filter_device = QCheckBox("Device")
filter_positioner = QCheckBox("Positioner")
filter_signal = QCheckBox("Signal")
filter_computed = QCheckBox("ComputedSignal")
controls.addWidget(filter_device)
controls.addWidget(filter_positioner)
controls.addWidget(filter_signal)
controls.addWidget(filter_computed)
combo = DeviceComboBox()
combo.devices = ["samx", "dev1", "dev2", "dev3", "dev4"]
combo.set_first_element_as_empty = True
layout.addWidget(combo)
def _apply_filters():
raw = class_input.text().strip()
if raw:
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
else:
combo.signal_class_filter = []
combo.filter_to_device = filter_device.isChecked()
combo.filter_to_positioner = filter_positioner.isChecked()
combo.filter_to_signal = filter_signal.isChecked()
combo.filter_to_computed_signal = filter_computed.isChecked()
class_input.textChanged.connect(_apply_filters)
filter_device.toggled.connect(_apply_filters)
filter_positioner.toggled.connect(_apply_filters)
filter_signal.toggled.connect(_apply_filters)
filter_computed.toggled.connect(_apply_filters)
_apply_filters()
widget.show()
app.exec_()

View File

@@ -31,12 +31,11 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
device_selected = Signal(str)
device_config_update = Signal()
PLUGIN = True
RPC = False
ICON_NAME = "edit_note"
def __init__(

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal
from qtpy.QtCore import QSize, Qt, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
@@ -22,18 +21,27 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
device: Device name to filter signals from.
signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details.
signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown.
ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
store_signal_config: Whether to store the full signal config in the combobox item data.
require_device: If True, signals are only shown/validated when a device is set.
Signals:
device_signal_changed: Emitted when the current text represents a valid signal selection.
signal_reset: Emitted when validation fails and the selection should be treated as cleared.
"""
USER_ACCESS = ["set_signal", "set_device", "signals"]
USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"]
ICON_NAME = "list_alt"
PLUGIN = True
RPC = True
RPC = False
device_signal_changed = Signal(str)
signal_reset = Signal()
def __init__(
self,
@@ -42,9 +50,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
config: DeviceSignalInputBaseConfig | None = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
signal_filter: list[Kind] | None = None,
signal_class_filter: list[str] | None = None,
ndim_filter: int | list[int] | None = None,
default: str | None = None,
arg_name: str | None = None,
store_signal_config: bool = True,
require_device: bool = False,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -57,26 +69,64 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self._set_first_element_as_empty = True
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
self._signal_class_filter = signal_class_filter or []
self._store_signal_config = store_signal_config
self.config.ndim_filter = ndim_filter or None
self._require_device = require_device
self._is_valid_input = False
# Note: Runtime arguments (e.g. device, default, arg_name) intentionally take
# precedence over values from the passed-in config. Full reconciliation and
# restoration of state between designer-provided config and runtime arguments
# is not yet implemented, as earlier attempts caused issues with QtDesigner.
self.currentTextChanged.connect(self.on_text_changed)
# Kind filtering is always applied; class filtering is additive. If signal_filter is None,
# we default to hinted+normal, even when signal_class_filter is empty or None. To disable
# kinds, pass an explicit signal_filter or toggle include_* after init.
if signal_filter is not None:
self.set_filter(signal_filter)
else:
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
if device is not None:
self.set_device(device)
if default is not None:
self.set_signal(default)
@SafeSlot(str)
def set_device(self, device: str | None):
"""
Set the device. When signal_class_filter is active, ensures base-class
logic runs and then refreshes the signal list to show only signals from
that device matching the signal class filter.
Args:
device(str): device name.
"""
super().set_device(device)
if self._signal_class_filter:
# Refresh the signal list to show only this device's signals
self.update_signals_from_signal_classes()
@SafeSlot()
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Update the filters for the combobox"""
"""Update the filters for the combobox.
When signal_class_filter is active, skip the normal Kind-based filtering.
Args:
content (dict | None): Content dictionary from BEC event.
metadata (dict | None): Metadata dictionary from BEC event.
"""
super().update_signals_from_filters(content, metadata)
if self._signal_class_filter:
self.update_signals_from_signal_classes()
return
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
@@ -118,6 +168,63 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
@SafeProperty("QStringList")
def signal_class_filter(self) -> list[str]:
"""
Get the list of signal classes to filter.
Returns:
list[str]: List of signal class names to filter.
"""
return self._signal_class_filter
@signal_class_filter.setter
def signal_class_filter(self, value: list[str] | None):
"""
Set the signal class filter.
Args:
value (list[str] | None): List of signal class names to filter, or None/empty
to disable class-based filtering and revert to the default behavior.
"""
normalized_value = value or []
self._signal_class_filter = normalized_value
self.config.signal_class_filter = normalized_value
if self._signal_class_filter:
self.update_signals_from_signal_classes()
else:
self.update_signals_from_filters()
@SafeProperty(int)
def ndim_filter(self) -> int:
"""Dimensionality filter for signals."""
return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1
@ndim_filter.setter
def ndim_filter(self, value: int):
self.config.ndim_filter = None if value < 0 else value
if self._signal_class_filter:
self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter)
@SafeProperty(bool)
def require_device(self) -> bool:
"""
If True, signals are only shown/validated when a device is set.
Note:
This property affects list rebuilding only when a signal_class_filter
is active. Without a signal class filter, the available signals are
managed by the standard Kind-based filtering.
"""
return self._require_device
@require_device.setter
def require_device(self, value: bool):
self._require_device = value
# Rebuild list when toggled, but only when using signal_class_filter
if self._signal_class_filter:
self.update_signals_from_signal_classes()
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
@@ -148,6 +255,109 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return True
return False
def get_signal_name(self) -> str:
"""
Get the signal name from the combobox.
Returns:
str: The signal name.
"""
signal_name = self.currentText()
index = self.findText(signal_name)
if index == -1:
return signal_name
signal_info = self.itemData(index)
if signal_info:
signal_name = signal_info.get("obj_name", signal_name)
return signal_name if signal_name else ""
def get_signal_config(self) -> dict | None:
"""
Get the signal config from the combobox for the currently selected signal.
Returns:
dict | None: The signal configuration dictionary or None if not available.
"""
if not self._store_signal_config:
return None
index = self.currentIndex()
if index == -1:
return None
signal_info = self.itemData(index)
return signal_info if signal_info else None
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
"""
Update the combobox with signals filtered by signal classes and optionally by ndim.
Uses device_manager.get_bec_signals() to retrieve signals.
If a device is set, only shows signals from that device.
Args:
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
If provided, only signals with matching ndim will be included.
Can be a single int or a list of ints. Use None to include all dimensions.
If not provided, uses the previously set ndim_filter.
"""
if not self._signal_class_filter:
return
if self._require_device and not self._device:
self.clear()
self._signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
# Update stored ndim_filter if a new one is provided
if ndim_filter is not None:
self.config.ndim_filter = ndim_filter
self.clear()
# Get signals with ndim filtering applied at the FilterIO level
signals = FilterIO.update_with_signal_class(
widget=self,
signal_class_filter=self._signal_class_filter,
client=self.client,
ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO
)
# Track signals for validation and FilterIO selection
self._signals = []
for device_name, signal_name, signal_config in signals:
# Filter by device if one is set
if self._device and device_name != self._device:
continue
if self._signal_filter:
kind_str = signal_config.get("kind_str")
if kind_str is not None and kind_str not in {
kind.name for kind in self._signal_filter
}:
continue
# Get storage_name for tooltip
storage_name = signal_config.get("storage_name", "")
# Store the full signal config as item data if requested
if self._store_signal_config:
self.addItem(signal_name, signal_config)
else:
self.addItem(signal_name)
# Track for validation
self._signals.append(signal_name)
# Set tooltip to storage_name (Qt.ToolTipRole = 3)
if storage_name:
self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole)
# Keep FilterIO selection in sync for validate_signal
FilterIO.set_selection(widget=self, selection=self._signals)
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
@@ -158,22 +368,44 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
@SafeSlot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
"""Validate and emit only when the signal is valid.
For a positioner, the readback value has to be renamed to the device name.
Args:
text (str): Text in the combobox.
When using signal_class_filter, device validation is skipped.
"""
if self.validate_device(self.device) is False:
return
if self.validate_signal(text) is False:
return
self.device_signal_changed.emit(text)
self.check_validity(text)
def check_validity(self, input_text: str) -> None:
"""Check if the current value is a valid signal and emit only when valid."""
if self._signal_class_filter:
if self._require_device and (not self._device or not input_text):
is_valid = False
else:
is_valid = self.validate_signal(input_text)
else:
if self._require_device and not self.validate_device(self._device):
is_valid = False
else:
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
if is_valid:
self._is_valid_input = True
self.device_signal_changed.emit(input_text)
self.setStyleSheet("border: 1px solid transparent;")
else:
self._is_valid_input = False
self.signal_reset.emit()
if self.isEnabled():
self.setStyleSheet("border: 1px solid red;")
@property
def selected_signal_comp_name(self) -> str:
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
@property
def is_valid_input(self) -> bool:
"""Whether the current text represents a valid signal selection."""
return self._is_valid_input
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
@@ -187,7 +419,14 @@ if __name__ == "__main__": # pragma: no cover
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
box = SignalComboBox(device="samx")
box = SignalComboBox(
device="waveform",
signal_class_filter=["AsyncSignal", "AsyncMultiSignal"],
ndim_filter=[1, 2],
store_signal_config=True,
signal_filter=[Kind.hinted, Kind.normal, Kind.config],
) # change signal filter class to test
box.setEditable(True)
layout.addWidget(box)
widget.show()
app.exec_()

View File

@@ -29,7 +29,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
device_signal_changed = Signal(str)
PLUGIN = True
RPC = True
RPC = False
ICON_NAME = "vital_signs"
def __init__(

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

@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup
class _DeviceListWiget(QListWidget):
class _DeviceListWidget(QListWidget):
def _item_iter(self):
return (self.item(i) for i in range(self.count()))
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included)
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0)

View File

@@ -34,13 +34,13 @@ class HashModel(str, Enum):
class DeviceResourceBackend(Protocol):
@property
def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all availble devices separated by tag groups. The same device may
"""A dictionary of all available devices separated by tag groups. The same device may
appear more than once (in different groups)."""
...
@property
def all_devices(self) -> set[HashableDevice]:
"""A set of all availble devices. The same device may not appear more than once."""
"""A set of all available devices. The same device may not appear more than once."""
...
@property

View File

@@ -1,7 +1,7 @@
"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV"""
from copy import deepcopy
from typing import Type
from typing import Any, Type
from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.logger import bec_logger
@@ -191,7 +191,7 @@ class DeviceConfigTemplate(QtWidgets.QWidget):
if widget is not None:
self._set_value_for_widget(widget, value)
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None:
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: Any) -> None:
"""
Set the value for a widget based on its type.

View File

@@ -1,7 +1,7 @@
"""Module for custom input widgets used in device configuration templates."""
from ast import literal_eval
from typing import Callable
from typing import Any, Callable
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -15,7 +15,7 @@ from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
def _try_literal_eval(value: any) -> any:
def _try_literal_eval(value: str) -> Any:
"""Consolidated function for literal evaluation of a value."""
if value in ["true", "True"]:
return True
@@ -407,7 +407,7 @@ class DeviceConfigField(BaseModel):
static: bool = False
placeholder_text: str | None = None
validation_callback: list[Callable[[str], bool]] | None = None
default: any = None
default: Any = None
model_config = ConfigDict(arbitrary_types_allowed=True)

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