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

Compare commits

...

269 Commits

Author SHA1 Message Date
18c104fc12 fix(monaco_dock): update editor metadata handling and improve open_file method 2025-11-26 10:56:41 +01:00
499d0a5986 refactor(developer_widget): enhance documentation and add missing imports 2025-11-26 10:56:30 +01:00
487feebcbf feat(developer_widget): add signal connection for focused editor changes to disable run button for macro files 2025-11-26 10:56:14 +01:00
73528eef18 fix(collapsible_tree_section): fix typo in the styleSheet 2025-11-24 11:45:33 +01:00
c2780d629c feat(advanced_dock_area): floating docks restore with relative geometry 2025-11-24 10:55:17 +01:00
d090f8f7e5 refactor(developer widget): type hint improvements 2025-11-24 10:55:17 +01:00
f9c03f1daa refactor: improvements to enum access 2025-11-24 10:55:17 +01:00
78485d3df0 fix(widget_state_manager): omits QIcon properties to prevent segfault 2025-11-24 10:55:17 +01:00
dc30e6b022 fix(widget_state_manager): visibility managed by parent 2025-11-24 10:55:17 +01:00
fd2b8918f5 feat(advanced_dock_area): instance lock for multiple ads in same session 2025-11-24 10:55:17 +01:00
0f392efdce fix(widgets): removed isVisible from all SafeProperties 2025-11-24 10:55:17 +01:00
e708ee56f4 fix(widget_state_manager): IDEExplorer plugin not initialised in designer 2025-11-24 10:55:17 +01:00
e74eea199e fix(widget_state_manager): skip property listed introduced 2025-11-24 10:55:17 +01:00
1b299b9334 feat(widget_state_manager): can serialize from root 2025-11-24 10:55:17 +01:00
aed22c605b fix(widget_state_manager): always setting visible to true 2025-11-24 10:55:17 +01:00
557371f3ba fix(client): client regenerated 2025-11-24 10:55:17 +01:00
31d01abe18 fix(bec_widget): improved qt enums; grab safeguard 2025-11-24 10:55:17 +01:00
86a966f10f fix(widget_io): find ancestor returns correct type 2025-11-24 10:55:17 +01:00
a9039c5ee6 fix(qt_ads): pythons stubs match structure of PySide6QtAds 2025-11-24 10:55:17 +01:00
0119332bcf fix(ide_explorer): light mode fixed 2025-11-24 10:55:17 +01:00
22651f1c58 fix(widget_state_manager): visible is always true 2025-11-24 10:55:17 +01:00
bd2d47f18f refactor(main_app): adapted for DockAreaWidget changes 2025-11-24 10:55:17 +01:00
00e4786651 refactor(developer_view): changed to use DockAreaWidget 2025-11-24 10:55:17 +01:00
6c6408e87f refactor(monaco_dock): changed to use DockAreaWidget 2025-11-24 10:55:17 +01:00
d259de227d feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants 2025-11-24 10:55:17 +01:00
28c0b3b18b fix(main_window): removed general forced cleanup 2025-11-24 10:55:17 +01:00
003fff7e4b fix(advanced_dock_area): disable developer mode switch 2025-11-24 10:55:17 +01:00
b9643691b9 feat(advanced_dock_area): UI/UX for profile management improved, saving directories logic adjusted 2025-11-24 10:55:17 +01:00
652bf2d00b fix(main_window): cleanup adjusted with shiboken6 2025-11-24 10:55:17 +01:00
a20d8b269d fix(dark_mode_button): skip settings added 2025-11-24 10:55:17 +01:00
1ab915468b fix(widget_state_manager): added shiboken check 2025-11-24 10:55:17 +01:00
578be47880 feat(bec_widget): save screenshot to bytes 2025-11-24 10:55:17 +01:00
5b364c5aa5 refactor: improve toolbar actions typing 2025-11-21 10:59:43 +01:00
38134ec849 WILL BE REMOVED AFTER REBASE fix(fakeredis): add support for additional args
(cherry picked from commit c9455672b5)
2025-11-19 18:15:44 +01:00
937bea5103 WILL BE REMOVED AFTER REBASE fix(test): removed duplicate test in crosshair
(cherry picked from commit d00d786399)
2025-11-18 16:51:52 +01:00
0ba14b2dbb WILL BE REMOVED AFTER REBASE build: pyqtgraph pin to 0.13.7
(cherry picked from commit a4c465dcaf)
2025-11-18 16:51:52 +01:00
7f488c09ef fix(becconnector): ophyd thread killer on exit + in conftest 2025-11-12 20:56:13 +01:00
8776784507 refactor(collapsible_tree_section): change add pushbuttons to qtoolbuttons 2025-11-04 17:22:40 +01:00
79d64c43d5 refactor(developer_view): developer view moved from examples to view module in applications 2025-11-04 17:22:40 +01:00
fa5135d2d7 feat(guided_tour): added option to register QActions from toolbar 2025-10-31 14:11:58 +01:00
e1e0fbd390 feat(guided_tour): add guided tour 2025-10-31 14:11:58 +01:00
4284d572ec docs(monaco_dock): add missing argument 2025-10-30 17:26:14 +01:00
96e7ca34ab fix: add metadata to scan control export 2025-10-30 17:26:14 +01:00
0d0eb1d8ee fix: developer view improvements and dependency updates 2025-10-30 17:26:14 +01:00
da1a3ddfc4 fix(dependencies): add copier and typer to project dependencies 2025-10-30 17:26:14 +01:00
7cd4f448b5 fix(view): apply view_id and view_title attributes to existing ViewBase instances 2025-10-30 17:26:14 +01:00
752d9ba575 refactor: move splitter weights to view; change developerview to viewbase 2025-10-30 17:26:14 +01:00
7a43111de0 feat: add developer view 2025-10-30 17:26:14 +01:00
b43abfafcd ci: install ttyd 2025-10-30 17:26:14 +01:00
4dc128c64a fix(actions): set default text position to 'under' 2025-10-30 17:26:14 +01:00
cb233b877d feat(jupyter_console_window): adjustment for general usage 2025-10-28 11:34:51 +01:00
16d88fe8df fix(jupyter_console): cleanup 2025-10-28 11:34:51 +01:00
6b5aa74934 fix(toolbar): create_action_with_text text will not change to tooltip when disable/enable/checked 2025-10-28 11:13:25 +01:00
140f126560 feat(ads): add pyi stub file to provide type hints for ads 2025-10-17 14:51:35 +02:00
a57d3c6e1c refactor: improve test coverage for help_inspector and device_manager_view 2025-10-16 16:33:46 +02:00
bb363d8280 test(device-manager-view): Add test for the device-manager-view initialization 2025-10-16 16:33:46 +02:00
b81e92b3ee test(device-manager-components): test for device manager components 2025-10-16 16:33:46 +02:00
bc77d3199e refactor(device-manager-view): cleanup, minor bugfixes 2025-10-16 16:33:46 +02:00
3522caba67 refactor: improve device-manager-view 2025-10-16 16:33:46 +02:00
979de3519b test(advanced_dock_area): tests fixed 2025-10-15 14:10:30 +02:00
4e38365d00 feat(advanced_dock_area): add 2D positioner box to device actions 2025-10-15 14:10:30 +02:00
caaa9e5d7f fix(advanced_dock_area): added terminal,bec_shell, removed VSCode 2025-10-15 14:10:30 +02:00
c97c50ffdb fix(web_console): added startup kwarg 2025-10-15 14:10:30 +02:00
cbf9d21b7d fix(advanced_dock_area): vs code removed from available docks 2025-10-15 14:10:30 +02:00
8e66bae6ec feat(help-inspector): add help inspector widget 2025-10-15 14:10:30 +02:00
b2af9c421c fix(signal_label): dispatcher unsubscribed in the cleanup 2025-10-15 14:10:30 +02:00
de5c2e37ed fix(client): abort, reset, stop button removed from RPC access 2025-10-15 14:10:30 +02:00
60c207f1fe test(color_utils): cleanup for pyqtgraph 2025-10-15 14:10:30 +02:00
c609a2c9cb test(device_input_base): added qtbot 2025-10-15 14:10:30 +02:00
8b9295356d test(busy_loader): tests added 2025-10-15 14:10:30 +02:00
e52afd569f feat(busy_loader): busy loader added to bec widget base class 2025-10-15 14:10:30 +02:00
91e8a72e07 refactor(device_manager_view): added labels to main toolbar 2025-10-15 14:10:30 +02:00
bd9db4f20e fix(available_device_resources): top toolbar size fixed 2025-10-15 14:10:29 +02:00
fcd7b3d642 perf(device_table_view): text wrapper delegate removed since it was not working correctly anyway 2025-10-15 14:10:29 +02:00
718f4564ab fix(device_manager_view): removed custom styling for overlay 2025-10-15 14:10:29 +02:00
657648c833 refactor(examples): wrong main app removed 2025-10-15 14:10:29 +02:00
99007d8cf5 feat(main_app): device manager implemented into main app 2025-10-15 14:10:29 +02:00
016e1af79e feat(actions): actions can be created with label text with beside or under alignment 2025-10-15 14:10:29 +02:00
1b7f9a3323 fix: mark processEvents for checks 2025-10-15 14:10:29 +02:00
12a454f762 refactor: cleanup 2025-10-15 14:10:29 +02:00
d8900a579f fix: preset classes for config dialog 2025-10-15 14:10:29 +02:00
ca9007a6e2 fix: tests 2025-10-15 14:10:29 +02:00
421e6d38e4 style: imports 2025-10-15 14:10:29 +02:00
fb2dfff4f3 refactor: redo device tester 2025-10-15 14:10:29 +02:00
0cad33cbd3 fix: check plugin exists before loading 2025-10-15 14:10:29 +02:00
9cd4e10408 feat: allow setting config in redis 2025-10-15 14:10:29 +02:00
f7f37509b6 style: typo 2025-10-15 14:10:29 +02:00
7e3de2c64e fix: slightly improve theming 2025-10-15 14:10:29 +02:00
bd9be10aaf fix: don't use deprecated api for CDockWidget 2025-10-15 14:10:29 +02:00
5fde098974 feat(device_manager): add device dialog with presets 2025-10-15 14:10:29 +02:00
97fddf0196 refactor: genericise config form 2025-10-15 14:10:29 +02:00
9f755997e2 fix: device table theming 2025-10-15 14:10:29 +02:00
cac1066861 refactor: available devices add+remove from toolbar 2025-10-15 14:10:29 +02:00
5281d380cb fix: add all devices to test list 2025-10-15 14:10:29 +02:00
24c213a705 feat: connect available devices to doc and yaml views 2025-10-15 14:10:29 +02:00
f41ea592bb feat: add/remove functionality for device table
refactor: use list of configs for general interfaces
2025-10-15 14:10:29 +02:00
a4e02460ad refactor: util for MimeData 2025-10-15 14:10:29 +02:00
a998793d00 feat(dm): apply shared selection signal util to view 2025-10-15 14:10:29 +02:00
be812322b0 feat: add shared selection signal util 2025-10-15 14:10:29 +02:00
7676e863b2 feat: connect config update to available devices 2025-10-15 14:10:29 +02:00
d467ecc5e0 fix: allow setting state with other conformation of config 2025-10-15 14:10:29 +02:00
72c86b9d6c feat: prepare available devices for dragging config 2025-10-15 14:10:29 +02:00
4ada3085a9 feat(device_table): prepare table for drop action 2025-10-15 14:10:29 +02:00
42fc7c8568 feat: add available devices to manager view 2025-10-15 14:10:29 +02:00
7f77b9b9f4 fix(dm): add constants.py 2025-10-15 14:10:29 +02:00
03441e93d0 feat: add available device resource browser 2025-10-15 14:10:29 +02:00
73731c8142 feat: add ListOfExpandableFrames util 2025-10-15 14:10:29 +02:00
17bccb8211 chore: update qtmonaco dependency 2025-10-15 14:10:29 +02:00
7935b9b820 refactor: refactor device_manager_view 2025-10-15 14:10:29 +02:00
5895147c00 feat(dm-view): initial commit for config_view, ophyd_test and dm_widget 2025-10-15 14:10:29 +02:00
6f78c914b7 fix(colors): accent colors fetching if theme not provided 2025-10-15 14:10:29 +02:00
c20bc00f45 test(main_app): test extended 2025-10-15 14:10:29 +02:00
67d50d7b68 feat(main_app):views with examples for enter and exit hook 2025-10-15 14:10:29 +02:00
3a78c1b177 feat(main_app): main app with interactive app switcher 2025-10-15 14:10:29 +02:00
66a29d1f07 test: remove outdated tests
Note: The stylesheet is now set by qthemes, not the widget itself. As a result, the widget-specific stylesheet remains empty.
2025-10-15 14:10:29 +02:00
c72789c8fd feat: add SafeConnect 2025-10-15 14:10:29 +02:00
fa642aaf49 fix: process all deletion events before applying a new theme.
Note: this can be dropped once qthemes is updated.
2025-10-15 14:10:29 +02:00
2aef05056c refactor: move to qthemes 1.1.2 2025-10-15 14:10:27 +02:00
e0d65b42f4 test: apply theme on qapp creation 2025-10-15 14:10:14 +02:00
8c226b66c6 refactor(spinner): improve enum access 2025-10-15 14:10:14 +02:00
39809e88b8 fix(themes): move apply theme from BECWidget class to server init 2025-10-15 14:10:14 +02:00
3dba8321f2 fix(BECWidget): ensure that theme changes are only triggered from alive Qt objects 2025-10-15 14:10:14 +02:00
0daeb389ef test: fix tests for qtheme v1 2025-10-15 14:10:12 +02:00
bdd732b236 ci: add artifact upload 2025-10-15 14:07:36 +02:00
d21464f98f test: fixes after theme changes 2025-10-15 14:07:36 +02:00
b02994558b build: add missing darkdetect dependency 2025-10-15 14:07:36 +02:00
b2d6b8dac0 fix(compact_popup): import from qtpy instead of pyside6 2025-10-15 14:07:36 +02:00
3e23ba9aa2 chore: fix formatter 2025-10-15 14:07:36 +02:00
daeec7e0c9 fix: compact popup layout spacing 2025-10-15 14:07:36 +02:00
03fe3a83c4 fix: remove pyqtgraph styling logic 2025-10-15 14:07:36 +02:00
43d49276ce fix: tree items due to pushbutton margins 2025-10-15 14:07:36 +02:00
b6f8866667 fix: device combobox change paint event to stylesheet change 2025-10-15 14:07:36 +02:00
dcdf5a4a54 fix(toolbar): toolbar menu button fixed 2025-10-15 14:07:36 +02:00
125c94cbbe fix:queue abort button fixed 2025-10-15 14:07:36 +02:00
138fd1bea7 fix(bec_widgets): adapt to bec_qthemes 1.0 2025-10-15 14:07:36 +02:00
63b7f92f22 build(bec_qthemes): version 1.0 dependency 2025-10-15 14:07:33 +02:00
66f01dc9a6 refactor(advanced_dock_area): profile tools moved to separate module 2025-10-15 14:07:17 +02:00
d4ea9f2992 fix(advanced_dock_area): dock manager global flags initialised in BW init to prevent segfault 2025-10-15 14:07:17 +02:00
9b9b8ab718 feat(advanced_dock_area): ads has default direction 2025-10-15 14:07:17 +02:00
6b1b7a6b56 refactor(advanced_dock_area): ads changed to separate widget 2025-10-15 14:07:17 +02:00
4e96d6c53c fix(bec_widgets): by default the linux display manager is switched to xcb 2025-10-15 14:07:17 +02:00
cbcf23965b feat(advanced_dock_area): added ads based dock area with profiles 2025-10-15 14:07:17 +02:00
24f54fc252 refactor(bec_main_window): main app theme renamed to View 2025-10-15 14:07:17 +02:00
6643dc2b57 feat(bec_widget): attach/detach method for all widgets + client regenerated 2025-10-15 14:07:17 +02:00
a58fb3fa53 fix(widget_state_manager): state manager can save to already existing settings
wip widget state manager saving loading file logic
2025-10-15 14:07:17 +02:00
5ecdaf4121 fix(widget_state_manager): state manager can save all properties recursively 2025-10-15 14:07:17 +02:00
77295f13f7 refactor(widget_io): ancestor hierarchy methods consolidated 2025-10-15 14:07:17 +02:00
faa0aa659d feat(widget_io): widget hierarchy find_ancestor added 2025-10-15 14:07:17 +02:00
00c0232833 feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2025-10-15 14:07:17 +02:00
8f359f8d37 refactor(bec_connector): signals renamed 2025-10-15 14:07:17 +02:00
5a49c41a52 fix(bec_connector): added name established signal for listeners 2025-10-15 14:07:17 +02:00
e4fb126fad fix(bec_connector): dedicated remove signal added for listeners 2025-10-15 14:07:16 +02:00
bf123550e1 build: PySide6-QtAds dependency added 2025-10-15 14:07:16 +02:00
semantic-release
741ca2fd8a 2.41.1
Automatically generated by python-semantic-release
2025-10-15 11:25:47 +00:00
3941050883 fix(dependencies): bec lib versions fixed 2025-10-15 13:25:01 +02:00
semantic-release
1d746c6829 2.41.0
Automatically generated by python-semantic-release
2025-10-15 10:36:45 +00:00
ef27de40ce fix(image_roi): delete button added to compact version 2025-10-15 12:35:51 +02:00
37df95ead8 fix(image_roi): rois can be removed with right click context menu 2025-10-15 12:35:51 +02:00
c87a6cfce9 feat(image_roi_tree): compact mode added 2025-10-15 12:35:51 +02:00
3d807eaa63 refactor(serializer): upgrade to new serializer interface 2025-10-13 16:11:47 +02:00
28ac9c5cc3 build(bec_lib): version bump to 3.69.3 2025-10-09 15:36:18 +02:00
1dd20d5986 test(deviceconfig-form-update): Add onFailure default to test 2025-10-09 15:36:18 +02:00
semantic-release
13299aeeb3 2.40.0
Automatically generated by python-semantic-release
2025-10-08 11:41:33 +00:00
d681ba538b fix(waveform): cleanup of scan_history dialog if not closed manually before widget 2025-10-08 13:40:48 +02:00
2bf489600e fix(waveform): safeguard for _scan_history_closed 2025-10-08 13:40:48 +02:00
7e88a002b6 fix(waveform): safeguard for if scan_item is a list 2025-10-08 13:40:48 +02:00
20a59af648 fix(curve_tree): scans are always fetched by scan ids 2025-10-08 13:40:48 +02:00
540cfc37be fix(waveform): safeguard added to the fetching history data 2025-10-08 13:40:48 +02:00
e59f27a22d fix(waveform): if scan id and scan number is provided, the scan is fetched from the scan id 2025-10-08 13:40:48 +02:00
df8065ea40 fix(curve_tree): safeguard fetching scan numbers from BEC client 2025-10-08 13:40:48 +02:00
2f3dc2ce6b build(bec_lib): bec_lib dependency raised to 3.68 2025-10-08 13:40:48 +02:00
a006f95f21 test(plotting_framework_e2e): fetching history curve 2025-10-08 13:40:48 +02:00
8111a4a21b fix(curve_tree): fetching scan numbers directly from the bec client 2025-10-08 13:40:48 +02:00
962ab774e6 fix(waveform): fetching scan number is not done from list but from .get_by_scan_number 2025-10-08 13:40:48 +02:00
2f798be7b0 refactor(test_waveform): test waveform renamed 2025-10-08 13:40:48 +02:00
5a5d32312b test(waveform,curve_tree): test extended to cover history curve behaviour 2025-10-08 13:40:48 +02:00
0844a9e119 test(conftest): suppress_message_box for error popups fixture autouse True 2025-10-08 13:40:48 +02:00
db7dd4f8d4 fix(waveform): x_data checked with is scalar instead of len() 2025-10-08 13:40:48 +02:00
f083dff612 feat(waveform): new type of curve - history curve 2025-10-08 13:40:48 +02:00
4be70580a6 refactor(waveform): separate method to fetch scan item from history 2025-10-08 13:40:48 +02:00
d19001c94e fix(waveform): update x suffix label with x property change, do not wait for next update cycle 2025-10-08 13:40:48 +02:00
f25f86522f chore: add dependabot config 2025-10-07 11:12:10 +02:00
semantic-release
948283bc13 2.39.1
Automatically generated by python-semantic-release
2025-10-07 09:00:10 +00:00
50696bce4c fix: explicitly pass the cached readout flag 2025-10-07 10:59:22 +02:00
semantic-release
1d988a4c57 2.39.0
Automatically generated by python-semantic-release
2025-09-24 16:28:40 +00:00
565c0bd1e7 feat(rpc_base): windows can be raised to front from CLI 2025-09-24 11:27:47 -05:00
975404f483 fix(rpc): fix hide/show 2025-09-24 11:27:47 -05:00
semantic-release
165e5e7d84 2.38.4
Automatically generated by python-semantic-release
2025-09-23 15:05:34 +00:00
108ddae6ca fix(image): add support for specifying preview signals through cli 2025-09-23 17:01:00 +02:00
semantic-release
9737acad58 2.38.3
Automatically generated by python-semantic-release
2025-09-23 14:19:21 +00:00
65bc5f5421 fix(ringprogressbar): fix client signature 2025-09-23 16:18:33 +02:00
475ca9f2d8 fix(connector): only flush pending events 2025-09-23 16:18:33 +02:00
bbb5fc6ce1 fix(ringprogressbar): various fixes and improvements 2025-09-23 16:18:33 +02:00
b1b6c5e6a5 test(ringprogressbar): extend e2e test 2025-09-23 16:18:33 +02:00
3e339348dd chore: deprecate 3.10, add 3.13 2025-09-15 13:48:32 +02:00
semantic-release
4f075151d5 2.38.2
Automatically generated by python-semantic-release
2025-09-11 15:01:23 +00:00
0a24ac2c40 fix(waveform):autorange on scan_status 2025-09-11 16:59:35 +02:00
3a2ec9f1b7 test(crosshair): visibility test added with plotbase fixture 2025-09-11 16:59:35 +02:00
4dc4ede1d2 fix(plot_base): crosshair items are excluded from visible curves and from auto_range 2025-09-11 16:59:35 +02:00
556832fd48 fix(waveform): changing curve visibility refresh markers 2025-09-11 16:59:35 +02:00
72b6f74252 fix(crosshair): ignore fetching data and markers from invisible items 2025-09-11 16:59:35 +02:00
b703b37bbd fix(plot_base): visible items injected into plot item 2025-09-11 16:59:35 +02:00
18ef35f22a docs: move to autoapi 2025-09-10 15:05:54 +02:00
fe67a4f325 ci: fix stale issues job permissions; add workflow dispatch option 2025-08-31 09:59:16 +02:00
semantic-release
f1c3d77a45 2.38.1
Automatically generated by python-semantic-release
2025-08-22 10:06:47 +00:00
ad7cdc60dd fix: move thefuzz dependency to prod 2025-08-22 12:06:01 +02:00
semantic-release
ba047fd776 2.38.0
Automatically generated by python-semantic-release
2025-08-19 15:12:14 +00:00
6e05157abb feat(device_manager): DeviceManager view of config session 2025-08-19 17:11:24 +02:00
semantic-release
f4bc759e72 2.37.0
Automatically generated by python-semantic-release
2025-08-19 14:52:20 +00:00
1bec9bd9b2 feat: add explorer widget 2025-08-19 16:51:38 +02:00
semantic-release
8b013d5dce 2.36.0
Automatically generated by python-semantic-release
2025-08-18 10:45:14 +00:00
f2e5a85e61 feat(scan control): add support for literals 2025-08-18 12:44:29 +02:00
semantic-release
a2f8880459 2.35.0
Automatically generated by python-semantic-release
2025-08-14 07:16:53 +00:00
926d722955 feat(property_manager): property manager widget 2025-08-14 09:16:04 +02:00
44ba7201b4 build: PySide6 upgraded to 6.9.0 2025-08-12 19:56:29 +02:00
semantic-release
0717426db2 2.34.0
Automatically generated by python-semantic-release
2025-08-07 13:39:47 +00:00
f4af6ebc5f fix: use better source for plugin repo name 2025-08-07 15:39:07 +02:00
a923f12c97 feat: autoformat compiled file and add docs 2025-08-07 15:39:07 +02:00
a5a7607a83 tests: add tests for widget creator 2025-08-07 15:39:07 +02:00
9de548446b fix: plugin widget import machinery
- lazy import client so plugin widgets can import BECWidgets which use
  it indirectly
- exclude classes originating from bec_widgets core from plugin
  discovery
- better errors
2025-08-07 15:39:07 +02:00
49ac7decf7 feat(plugin manager): add cli commands 2025-08-07 15:39:07 +02:00
semantic-release
092bed38fa 2.33.3
Automatically generated by python-semantic-release
2025-07-31 11:10:38 +00:00
50c84a766a refactor(scan-history): add spinner for loading time of history 2025-07-31 13:09:47 +02:00
d22a3317ba refactor: use client callback for scan history reload 2025-07-31 13:09:47 +02:00
6df1d0c31f fix(scan-history-view): account for async loading of scan history 2025-07-31 13:09:47 +02:00
946752a4b0 refactor(scan-history): fix insert logic; cleanup 2025-07-31 13:09:47 +02:00
c1f62ad6cb refactor: make ids a set, cleanup 2025-07-31 13:09:47 +02:00
a5adf3a97d refactor: improve scan history performance on loading full scan lists 2025-07-31 13:09:47 +02:00
semantic-release
76e3e0b60f 2.33.2
Automatically generated by python-semantic-release
2025-07-31 07:27:50 +00:00
f18eeb9c5d fix: don't warn on empty DeviceEdit init 2025-07-31 09:26:59 +02:00
32ce8e2818 fix: remove config, directly set device+signal 2025-07-31 09:26:59 +02:00
23413cffab fix: delete choice dialog on close 2025-07-31 09:26:59 +02:00
David Perl
4bbb8fa519 fix: display short lists in SignalDisplay 2025-07-31 09:26:59 +02:00
semantic-release
a972369a72 2.33.1
Automatically generated by python-semantic-release
2025-07-31 06:50:30 +00:00
cd81e7f9ba fix(cli): ensure guis are not started twice 2025-07-31 08:49:48 +02:00
semantic-release
e2b8118f67 2.33.0
Automatically generated by python-semantic-release
2025-07-29 13:24:20 +00:00
5f925ba4e3 build: update bec and qtmonaco min dependencies 2025-07-29 15:23:36 +02:00
fc68d2cf2d feat(monaco): add insert, delete and lsp header 2025-07-29 15:23:36 +02:00
627b49b33a feat(monaco): add vim mode 2025-07-29 15:23:36 +02:00
a51ef04cdf fix(monaco): forward text changed signal 2025-07-29 15:23:36 +02:00
40f4bce285 test(web console): add tests for the web console 2025-07-29 15:23:36 +02:00
2b9fe6c959 feat(web console): add signal to indicate when the js backend is initialized 2025-07-29 15:23:36 +02:00
c2e16429c9 feat(web console): add set_readonly method 2025-07-29 15:23:36 +02:00
semantic-release
85ce2aa136 2.32.0
Automatically generated by python-semantic-release
2025-07-29 13:09:07 +00:00
fd5af01842 feat(dock area): add screenshot toolbar action 2025-07-29 15:08:17 +02:00
8a214c8978 feat(rpc_timeout): add decorator to override the rpc timeout 2025-07-29 15:08:17 +02:00
semantic-release
f3214445f2 2.31.3
Automatically generated by python-semantic-release
2025-07-29 12:57:40 +00:00
6bf84aea25 fix(waveform): fallback mechanism for auto mode to use index if scan_report_devices are not available 2025-07-29 14:56:54 +02:00
semantic-release
aace071f11 2.31.2
Automatically generated by python-semantic-release
2025-07-29 12:05:13 +00:00
bf86a030a0 fix(bec widgets): always call cleanup of child widgets on cleanup 2025-07-29 14:04:24 +02:00
semantic-release
358c979bf2 2.31.1
Automatically generated by python-semantic-release
2025-07-29 09:19:55 +00:00
c1bdc506e8 fix(image_base): fix cleanup of uninitialized image layer 2025-07-29 11:19:07 +02:00
semantic-release
4febfb79df 2.31.0
Automatically generated by python-semantic-release
2025-07-29 07:02:55 +00:00
0854175acb test(launch_window): MainWindow raise test removed, features is supported now 2025-07-29 09:01:01 +02:00
e090ac49b7 fix(launch_window): logic for custom main window apps adjusted 2025-07-29 09:01:01 +02:00
e4521d9528 feat(bec_main_window): plugin and rpc created 2025-07-29 09:01:01 +02:00
1d0490fff4 fix(bec_main_window): main window have unified status bar on macOS 2025-07-29 09:01:01 +02:00
10cbb9a05c refactor(widgets): all plugins regenerated 2025-07-29 09:01:01 +02:00
7073e75adf fix(scan_progressbar): added kwargs to init 2025-07-29 09:01:01 +02:00
e42ffd7c01 fix(color_button_native): removed BECWidget inheritance 2025-07-29 09:01:01 +02:00
2bd6d00899 fix(decimal_spinbox): removed BECWidget inheritance 2025-07-29 09:01:01 +02:00
c2a918ef4b fix(plugin_utils): plugins can be created from QWidgets, no need for BECWidget base class for plugin creation 2025-07-29 09:01:01 +02:00
6bbf5126cf fix(widgets): added missing __init__ files 2025-07-29 09:01:01 +02:00
728d4efd96 fix(utils): plugin template createWidget do not initialise widgets by default 2025-07-29 09:01:01 +02:00
semantic-release
7926969996 2.30.6
Automatically generated by python-semantic-release
2025-07-26 12:44:29 +00:00
61e5bde15f fix(waveform): autorange is applied with 150ms delay after curve is added 2025-07-26 14:43:51 +02:00
semantic-release
c8aa770de3 2.30.5
Automatically generated by python-semantic-release
2025-07-25 17:44:39 +00:00
4d5df9608a refactor(positioner-box): cleanup, accept float precision 2025-07-25 19:43:52 +02:00
b718b438ba fix(positioner-box): Test to fix handling of none integer values for precision 2025-07-25 19:43:52 +02:00
316 changed files with 29081 additions and 1997 deletions

View File

@@ -53,6 +53,7 @@ runs:
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: Install Python dependencies
shell: bash

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets

View File

@@ -57,6 +57,14 @@ jobs:
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: image-references
path: bec_widgets/tests/reference_failures/
if-no-files-found: ignore
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:

View File

@@ -2,10 +2,14 @@ name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:

View File

@@ -1,289 +0,0 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
include:
- template: Security/Secret-Detection.gitlab-ci.yml
- project: "bec/awi_utils"
file: "/templates/check-packages-job.yml"
inputs:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
pip_args: ".[dev]"
# different stages in the pipeline
stages:
- Formatter
- test
- AdditionalTests
- End2End
- Deploy
.install-qt-webengine-deps: &install-qt-webengine-deps
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e ./bec/pytest_bec_e2e
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- *install-qt-webengine-deps
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install -e ./[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint:
stage: Formatter
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
- echo "Pylint score is $PYLINT_SCORE"
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
needs: []
allow_failure: true
before_script:
- pip install pylint pylint-exit anybadge
- apt-get update
- apt-get install -y bc
script:
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
- echo "Changed Python files:"
- $CHANGED_FILES
# Run pylint only on changed files
- mkdir ./pylint
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
- echo "Pylint score is $PYLINT_SCORE"
# Fail the job if the pylint score is below 9
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
matrix:
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
stage: AdditionalTests
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3:25.1.1-2
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- conda config --show-sources
- conda config --add channels conda-forge
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
- conda config --remove channels https://repo.anaconda.com/pkgs/main
- conda config --remove channels https://repo.anaconda.com/pkgs/r
- conda config --show-sources
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11
- conda init bash
- source ~/.bashrc
- conda activate test-environment
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- cd ../
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
artifacts:
when: on_failure
paths:
- ./logs/*.log
expire_in: 1 week
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
semver:
stage: Deploy
needs: ["tests"]
script:
- git config --global user.name "ci_update_bot"
- git config --global user.email "ci_update_bot@bec.ch"
- git checkout "$CI_COMMIT_REF_NAME"
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
# delete all local tags
- git tag -l | xargs git tag -d
- git fetch --tags
- git tag
# build and publish package
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
- semantic-release publish
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
pages:
stage: Deploy
needs: ["semver"]
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: "$CI_COMMIT_TAG != null"
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/

View File

@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.10
py-version=3.11
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.

View File

@@ -1,6 +1,453 @@
# CHANGELOG
## v2.41.1 (2025-10-15)
### Bug Fixes
- **dependencies**: Bec lib versions fixed
([`3941050`](https://github.com/bec-project/bec_widgets/commit/3941050883a791f800ab7178af2435ac14f837b6))
## v2.41.0 (2025-10-15)
### Bug Fixes
- **image_roi**: Delete button added to compact version
([`ef27de4`](https://github.com/bec-project/bec_widgets/commit/ef27de40ceee8375d95a0f3a8e451b7d05d0ae2c))
- **image_roi**: Rois can be removed with right click context menu
([`37df95e`](https://github.com/bec-project/bec_widgets/commit/37df95ead8d6a07a6c5794a97a486d9f380004cc))
### Build System
- **bec_lib**: Version bump to 3.69.3
([`28ac9c5`](https://github.com/bec-project/bec_widgets/commit/28ac9c5cc369bdfa712c70c45591243631c65066))
### Features
- **image_roi_tree**: Compact mode added
([`c87a6cf`](https://github.com/bec-project/bec_widgets/commit/c87a6cfce9c36588b32f5279e63072bc2646c36f))
### Refactoring
- **serializer**: Upgrade to new serializer interface
([`3d807ea`](https://github.com/bec-project/bec_widgets/commit/3d807eaa63980fd2bb11661696c4d8548fffde8c))
### Testing
- **deviceconfig-form-update**: Add onFailure default to test
([`1dd20d5`](https://github.com/bec-project/bec_widgets/commit/1dd20d5986485f3bfe7ee02596ca23027ec4b756))
## v2.40.0 (2025-10-08)
### Bug Fixes
- **curve_tree**: Fetching scan numbers directly from the bec client
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
- **curve_tree**: Safeguard fetching scan numbers from BEC client
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
- **curve_tree**: Scans are always fetched by scan ids
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
- **waveform**: Safeguard added to the fetching history data
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
- **waveform**: Safeguard for _scan_history_closed
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
- **waveform**: Safeguard for if scan_item is a list
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
- **waveform**: X_data checked with is scalar instead of len()
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
### Build System
- **bec_lib**: Bec_lib dependency raised to 3.68
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
### Chores
- Add dependabot config
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
### Features
- **waveform**: New type of curve - history curve
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
### Refactoring
- **test_waveform**: Test waveform renamed
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
- **waveform**: Separate method to fetch scan item from history
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
### Testing
- **conftest**: Suppress_message_box for error popups fixture autouse True
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
- **plotting_framework_e2e**: Fetching history curve
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
- **waveform,curve_tree**: Test extended to cover history curve behaviour
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
## v2.39.1 (2025-10-07)
### Bug Fixes
- Explicitly pass the cached readout flag
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
## v2.39.0 (2025-09-24)
### Bug Fixes
- **rpc**: Fix hide/show
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
### Features
- **rpc_base**: Windows can be raised to front from CLI
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
## v2.38.4 (2025-09-23)
### Bug Fixes
- **image**: Add support for specifying preview signals through cli
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
## v2.38.3 (2025-09-23)
### Bug Fixes
- **connector**: Only flush pending events
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
- **ringprogressbar**: Fix client signature
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
- **ringprogressbar**: Various fixes and improvements
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
### Chores
- Deprecate 3.10, add 3.13
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
### Testing
- **ringprogressbar**: Extend e2e test
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
## v2.38.2 (2025-09-11)
### Bug Fixes
- **crosshair**: Ignore fetching data and markers from invisible items
([`72b6f74`](https://github.com/bec-project/bec_widgets/commit/72b6f74252e1f36339945c549049b166cccf3561))
- **plot_base**: Crosshair items are excluded from visible curves and from auto_range
([`4dc4ede`](https://github.com/bec-project/bec_widgets/commit/4dc4ede1d251d081e5bcf3d37fcc784982c9258e))
- **plot_base**: Visible items injected into plot item
([`b703b37`](https://github.com/bec-project/bec_widgets/commit/b703b37bbdbf97182b58ac4c69c1384fa78d0c12))
- **waveform**: Changing curve visibility refresh markers
([`556832f`](https://github.com/bec-project/bec_widgets/commit/556832fd48bcb16b95df8cf91417d7045bbca2a3))
### Continuous Integration
- Fix stale issues job permissions; add workflow dispatch option
([`fe67a4f`](https://github.com/bec-project/bec_widgets/commit/fe67a4f325cbd41f13102e5698d86ed9e90b048e))
### Documentation
- Move to autoapi
([`18ef35f`](https://github.com/bec-project/bec_widgets/commit/18ef35f22a1b7496b13f833e63a4f3875e1497e3))
### Testing
- **crosshair**: Visibility test added with plotbase fixture
([`3a2ec9f`](https://github.com/bec-project/bec_widgets/commit/3a2ec9f1b74c4bb5f239940b874576a877ce45c0))
## v2.38.1 (2025-08-22)
### Bug Fixes
- Move thefuzz dependency to prod
([`ad7cdc6`](https://github.com/bec-project/bec_widgets/commit/ad7cdc60dd6da6c5291f8b42932aacb12aa671a6))
## v2.38.0 (2025-08-19)
### Features
- **device_manager**: Devicemanager view of config session
([`6e05157`](https://github.com/bec-project/bec_widgets/commit/6e05157abb61ec569eec10ff7295c28cb6a2ec45))
## v2.37.0 (2025-08-19)
### Features
- Add explorer widget
([`1bec9bd`](https://github.com/bec-project/bec_widgets/commit/1bec9bd9b2238ed484e8d25e691326efe5730f6b))
## v2.36.0 (2025-08-18)
### Features
- **scan control**: Add support for literals
([`f2e5a85`](https://github.com/bec-project/bec_widgets/commit/f2e5a85e616aa76d4b7ad3b3c76a24ba114ebdd1))
## v2.35.0 (2025-08-14)
### Build System
- Pyside6 upgraded to 6.9.0
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
### Features
- **property_manager**: Property manager widget
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
## v2.34.0 (2025-08-07)
### Bug Fixes
- Plugin widget import machinery
([`9de5484`](https://github.com/bec-project/bec_widgets/commit/9de548446b9975c0f692757c66ffa07b9a849f15))
- lazy import client so plugin widgets can import BECWidgets which use it indirectly - exclude
classes originating from bec_widgets core from plugin discovery - better errors
- Use better source for plugin repo name
([`f4af6eb`](https://github.com/bec-project/bec_widgets/commit/f4af6ebc5fabf5b62ec87b580476d93d52690b08))
### Features
- Autoformat compiled file and add docs
([`a923f12`](https://github.com/bec-project/bec_widgets/commit/a923f12c974192909222fcada9eca97325866d74))
- **plugin manager**: Add cli commands
([`49ac7de`](https://github.com/bec-project/bec_widgets/commit/49ac7decf7d4cf461e6437f7285dc6967ee36d96))
## v2.33.3 (2025-07-31)
### Bug Fixes
- **scan-history-view**: Account for async loading of scan history
([`6df1d0c`](https://github.com/bec-project/bec_widgets/commit/6df1d0c31fb58c25b01e95e2247277ff2dd5d00e))
### Refactoring
- Improve scan history performance on loading full scan lists
([`a5adf3a`](https://github.com/bec-project/bec_widgets/commit/a5adf3a97d9ff05cef833445c1e6cd8f35a9a2fa))
- Make ids a set, cleanup
([`c1f62ad`](https://github.com/bec-project/bec_widgets/commit/c1f62ad6cb00d9b392a8e0b6247f5260dfb37256))
- Use client callback for scan history reload
([`d22a331`](https://github.com/bec-project/bec_widgets/commit/d22a3317baeccfcc4e074dcef4e3912301d210c5))
- **scan-history**: Add spinner for loading time of history
([`50c84a7`](https://github.com/bec-project/bec_widgets/commit/50c84a766a2b021768fb2c0e8ee00b8e5f058ba7))
- **scan-history**: Fix insert logic; cleanup
([`946752a`](https://github.com/bec-project/bec_widgets/commit/946752a4b05804c2f59cb5c21e4c1d11709a7d44))
## v2.33.2 (2025-07-31)
### Bug Fixes
- Delete choice dialog on close
([`23413cf`](https://github.com/bec-project/bec_widgets/commit/23413cffabe721e35bb5bb726ec34d74dc4ffe05))
- Display short lists in SignalDisplay
([`4bbb8fa`](https://github.com/bec-project/bec_widgets/commit/4bbb8fa519e8a90eebfcfa34e157493c9baa7880))
- Don't warn on empty DeviceEdit init
([`f18eeb9`](https://github.com/bec-project/bec_widgets/commit/f18eeb9c5dccbd9348b6ee6d1477a8b7925d40fc))
- Remove config, directly set device+signal
([`32ce8e2`](https://github.com/bec-project/bec_widgets/commit/32ce8e2818ceacda87e48399e3ed4df0cabb2335))
## v2.33.1 (2025-07-31)
### Bug Fixes
- **cli**: Ensure guis are not started twice
([`cd81e7f`](https://github.com/bec-project/bec_widgets/commit/cd81e7f9ba40be23f6b930d250f743276720b277))
## v2.33.0 (2025-07-29)
### Bug Fixes
- **monaco**: Forward text changed signal
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
### Build System
- Update bec and qtmonaco min dependencies
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
### Features
- **monaco**: Add insert, delete and lsp header
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
- **monaco**: Add vim mode
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
- **web console**: Add set_readonly method
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
- **web console**: Add signal to indicate when the js backend is initialized
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
### Testing
- **web console**: Add tests for the web console
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
## v2.32.0 (2025-07-29)
### Features
- **dock area**: Add screenshot toolbar action
([`fd5af01`](https://github.com/bec-project/bec_widgets/commit/fd5af0184279400ca6d8e5d2042f31be88d180f3))
- **rpc_timeout**: Add decorator to override the rpc timeout
([`8a214c8`](https://github.com/bec-project/bec_widgets/commit/8a214c897899d0d94d5f262591a001c127d1b155))
## v2.31.3 (2025-07-29)
### Bug Fixes
- **waveform**: Fallback mechanism for auto mode to use index if scan_report_devices are not
available
([`6bf84ae`](https://github.com/bec-project/bec_widgets/commit/6bf84aea2508ff01fe201c045ec055684da88593))
## v2.31.2 (2025-07-29)
### Bug Fixes
- **bec widgets**: Always call cleanup of child widgets on cleanup
([`bf86a03`](https://github.com/bec-project/bec_widgets/commit/bf86a030a08b325a08e031ff71d0716a2f2f122b))
## v2.31.1 (2025-07-29)
### Bug Fixes
- **image_base**: Fix cleanup of uninitialized image layer
([`c1bdc50`](https://github.com/bec-project/bec_widgets/commit/c1bdc506e8099f178acdccbe0e1109deeeaaca38))
## v2.31.0 (2025-07-29)
### Bug Fixes
- **bec_main_window**: Main window have unified status bar on macOS
([`1d0490f`](https://github.com/bec-project/bec_widgets/commit/1d0490fff428d51f2cdb7d35a954a7cd62cbb65c))
- **color_button_native**: Removed BECWidget inheritance
([`e42ffd7`](https://github.com/bec-project/bec_widgets/commit/e42ffd7c015a026d8e0967ac6b5866cbbea7bfed))
- **decimal_spinbox**: Removed BECWidget inheritance
([`2bd6d00`](https://github.com/bec-project/bec_widgets/commit/2bd6d0089955172134afb4d39939890026ed43f0))
- **launch_window**: Logic for custom main window apps adjusted
([`e090ac4`](https://github.com/bec-project/bec_widgets/commit/e090ac49b72fa15ebf1c09164ff3c6de577cb939))
- **plugin_utils**: Plugins can be created from QWidgets, no need for BECWidget base class for
plugin creation
([`c2a918e`](https://github.com/bec-project/bec_widgets/commit/c2a918ef4b77ccd7fa43d1bc0b907d55a17a6c95))
- **scan_progressbar**: Added kwargs to init
([`7073e75`](https://github.com/bec-project/bec_widgets/commit/7073e75adf0eeb81f4f8e27eb99fc1b7a395c751))
- **utils**: Plugin template createWidget do not initialise widgets by default
([`728d4ef`](https://github.com/bec-project/bec_widgets/commit/728d4efd9646ffcecd7d1a2f70988a7d7c799124))
- **widgets**: Added missing __init__ files
([`6bbf512`](https://github.com/bec-project/bec_widgets/commit/6bbf5126cf586063ed08d6cd489d6a9af28eac35))
### Features
- **bec_main_window**: Plugin and rpc created
([`e4521d9`](https://github.com/bec-project/bec_widgets/commit/e4521d95286bbc598c3c05f357d247d950477b71))
### Refactoring
- **widgets**: All plugins regenerated
([`10cbb9a`](https://github.com/bec-project/bec_widgets/commit/10cbb9a05cb96a791448caff4ffc4115b76146d7))
### Testing
- **launch_window**: Mainwindow raise test removed, features is supported now
([`0854175`](https://github.com/bec-project/bec_widgets/commit/0854175acbda1d4de71358aec028539552a26448))
## v2.30.6 (2025-07-26)
### Bug Fixes
- **waveform**: Autorange is applied with 150ms delay after curve is added
([`61e5bde`](https://github.com/bec-project/bec_widgets/commit/61e5bde15f0e1ebe185ddbe81cd71ad581ae6009))
## v2.30.5 (2025-07-25)
### Bug Fixes
- **positioner-box**: Test to fix handling of none integer values for precision
([`b718b43`](https://github.com/bec-project/bec_widgets/commit/b718b438bacff6eb6cd6015f1a67dcf75c05dce4))
### Refactoring
- **positioner-box**: Cleanup, accept float precision
([`4d5df96`](https://github.com/bec-project/bec_widgets/commit/4d5df9608a9438b9f6d7508c323eb3772e53f37d))
## v2.30.4 (2025-07-25)
### Bug Fixes

View File

@@ -5,7 +5,7 @@
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)

View File

@@ -1,4 +1,19 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]

View File

@@ -31,7 +31,7 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
@@ -395,20 +395,24 @@ class LaunchWindow(BECMainWindow):
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindow()
window = BECMainWindowNoRPC()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
# Load the custom UI file
"""
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
instantiate it directly; otherwise, embed it in a UILaunchWindow.
"""
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
# Parse the UI to detect top-level widget class
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
@@ -416,19 +420,22 @@ class LaunchWindow(BECMainWindow):
if widget is None:
raise ValueError("No widget found in the UI file.")
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
# Load the UI into a widget
loader = UILoader(None)
loaded = loader.loader(ui_file)
# Display the UI in a BECMainWindow
if isinstance(loaded, BECMainWindow):
window = loaded
window.object_name = filename
else:
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.setWindowTitle(f"BEC - {filename}")
window.show()
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
@@ -451,7 +458,7 @@ class LaunchWindow(BECMainWindow):
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
window = BECMainWindowNoRPC()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)

View File

@@ -0,0 +1,229 @@
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.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
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.widgets.containers.main_window.main_window import BECMainWindow
class BECMainApp(BECMainWindow):
def __init__(
self,
parent=None,
*args,
anim_duration: int = ANIMATION_DURATION,
show_examples: bool = False,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
self._show_examples = bool(show_examples)
# --- Compose central UI (sidebar + stack)
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
self.stack = QStackedWidget(self)
container = QWidget(self)
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.sidebar, 0)
layout.addWidget(self.stack, 1)
self.setCentralWidget(container)
# Mapping for view switching
self._view_index: dict[str, int] = {}
self._current_view_id: str | None = None
self.sidebar.view_selected.connect(self._on_view_selected)
self._add_views()
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.device_manager = DeviceManagerWidget(self)
self.developer_view = DeveloperView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
)
self.add_view(
icon="display_settings",
title="Device Manager",
id="device_manager",
widget=self.device_manager,
mini_text="DM",
)
self.add_view(
icon="code_blocks",
title="IDE",
widget=self.developer_view,
id="developer_view",
exclusive=True,
)
if self._show_examples:
self.add_section("Examples", "examples")
waveform_view_popup = WaveformViewPopup(
parent=self, id="waveform_view_popup", title="Waveform Plot"
)
waveform_view_stack = WaveformViewInline(
parent=self, 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",
)
self.set_current("dock_area")
self.sidebar.add_dark_mode_item()
# --- Public API ------------------------------------------------------
def add_section(self, title: str, id: str, position: int | None = None):
return self.sidebar.add_section(title, id, position)
def add_separator(self):
return self.sidebar.add_separator()
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
return self.sidebar.add_dark_mode_item(id=id, position=position)
def add_view(
self,
*,
icon: str,
title: str,
id: str,
widget: QWidget,
mini_text: str | None = None,
position: int | None = None,
from_top: bool = True,
toggleable: bool = True,
exclusive: bool = True,
) -> NavigationItem:
"""
Register a view in the stack and create a matching nav item in the sidebar.
Args:
icon(str): Icon name for the nav item.
title(str): Title for the nav item.
id(str): Unique ID for the view/item.
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.
from_top(bool, optional): Whether to count position from the top or bottom.
toggleable(bool, optional): Whether the nav item is toggleable.
exclusive(bool, optional): Whether the nav item is exclusive.
Returns:
NavigationItem: The created navigation item.
"""
item = self.sidebar.add_item(
icon=icon,
title=title,
id=id,
mini_text=mini_text,
position=position,
from_top=from_top,
toggleable=toggleable,
exclusive=exclusive,
)
# 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_title = title
else:
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
idx = self.stack.addWidget(view_widget)
self._view_index[id] = idx
return item
def set_current(self, id: str) -> None:
if id in self._view_index:
self.sidebar.activate_item(id)
# Internal: route sidebar selection to the stack
def _on_view_selected(self, vid: str) -> None:
# Determine current view
current_index = self.stack.currentIndex()
current_view = (
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
)
# Ask current view whether we may leave
if current_view is not None and hasattr(current_view, "on_exit"):
may_leave = current_view.on_exit()
if may_leave is False:
# Veto: restore previous highlight without re-emitting selection
if self._current_view_id is not None:
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
return
# Proceed with switch
idx = self._view_index.get(vid)
if idx is None or not (0 <= idx < self.stack.count()):
return
self.stack.setCurrentIndex(idx)
new_view = self.stack.widget(idx)
self._current_view_id = vid
if hasattr(new_view, "on_enter"):
new_view.on_enter()
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="BEC Main Application")
parser.add_argument(
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
)
# Let Qt consume the remaining args
args, qt_args = parser.parse_known_args(sys.argv[1:])
app = QApplication([sys.argv[0], *qt_args])
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
w.resize(width, height)
w.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
ANIMATION_DURATION = 500 # ms
class RevealAnimator:
"""Animate reveal/hide for a single widget using opacity + max W/H.
This keeps the widget always visible to avoid jitter from setVisible().
Collapsed state: opacity=0, maxW=0, maxH=0.
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
"""
def __init__(
self,
widget: QWidget,
duration: int = ANIMATION_DURATION,
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
initially_revealed: bool = False,
*,
animate_opacity: bool = True,
animate_width: bool = True,
animate_height: bool = True,
):
self.widget = widget
self.animate_opacity = animate_opacity
self.animate_width = animate_width
self.animate_height = animate_height
# Opacity effect
self.fx = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(self.fx)
# Animations
self.opacity_anim = (
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
)
self.width_anim = (
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
)
self.height_anim = (
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
)
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
if anim is not None:
anim.setDuration(duration)
anim.setEasingCurve(easing)
# Initialize to requested state
self.set_immediate(initially_revealed)
def _natural_sizes(self) -> tuple[int, int]:
sh = self.widget.sizeHint()
w = max(sh.width(), 1)
h = max(sh.height(), 1)
return w, h
def set_immediate(self, revealed: bool):
"""
Immediately set the widget to the target revealed/collapsed state.
Args:
revealed(bool): True to reveal, False to collapse.
"""
w, h = self._natural_sizes()
if self.animate_opacity:
self.fx.setOpacity(1.0 if revealed else 0.0)
if self.animate_width:
self.widget.setMaximumWidth(w if revealed else 0)
if self.animate_height:
self.widget.setMaximumHeight(h if revealed else 0)
def setup(self, reveal: bool):
"""
Prepare animations to transition to the target revealed/collapsed state.
Args:
reveal(bool): True to reveal, False to collapse.
"""
# Prepare animations from current state to target
target_w, target_h = self._natural_sizes()
if self.opacity_anim is not None:
self.opacity_anim.setStartValue(self.fx.opacity())
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
if self.width_anim is not None:
self.width_anim.setStartValue(self.widget.maximumWidth())
self.width_anim.setEndValue(target_w if reveal else 0)
if self.height_anim is not None:
self.height_anim.setStartValue(self.widget.maximumHeight())
self.height_anim.setEndValue(target_h if reveal else 0)
def add_to_group(self, group: QParallelAnimationGroup):
"""
Add the prepared animations to the given animation group.
Args:
group(QParallelAnimationGroup): The animation group to add to.
"""
if self.opacity_anim is not None:
group.addAnimation(self.opacity_anim)
if self.width_anim is not None:
group.addAnimation(self.width_anim)
if self.height_anim is not None:
group.addAnimation(self.height_anim)
def animations(self):
"""
Get a list of all animations (non-None) for adding to a group.
"""
return [
anim
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
if anim is not None
]

View File

@@ -0,0 +1,357 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy import QtWidgets
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
from qtpy.QtWidgets import (
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QScrollArea,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar_components import (
DarkModeNavItem,
NavigationItem,
SectionHeader,
SideBarSeparator,
)
class SideBar(QScrollArea):
view_selected = Signal(str)
toggled = Signal(bool)
def __init__(
self,
parent=None,
title: str = "Control Panel",
collapsed_width: int = 56,
expanded_width: int = 250,
anim_duration: int = ANIMATION_DURATION,
):
super().__init__(parent=parent)
self.setObjectName("SideBar")
# private attributes
self._is_expanded = False
self._collapsed_width = collapsed_width
self._expanded_width = expanded_width
self._anim_duration = anim_duration
# containers
self.components = {}
self._item_opts: dict[str, dict] = {}
# Scroll area properties
self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setFrameShape(QtWidgets.QFrame.NoFrame)
self.setFixedWidth(self._collapsed_width)
# Content widget holding buttons for switching views
self.content = QWidget(self)
self.content_layout = QVBoxLayout(self.content)
self.content_layout.setContentsMargins(0, 0, 0, 0)
self.content_layout.setSpacing(4)
self.setWidget(self.content)
# Track active navigation item
self._active_id = None
# Top row with title and toggle button
self.toggle_row = QWidget(self)
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
self.title_label = QLabel(title, self)
self.title_label.setObjectName("TopTitle")
self.title_label.setStyleSheet("font-weight: 600;")
self.title_fx = QGraphicsOpacityEffect(self.title_label)
self.title_label.setGraphicsEffect(self.title_fx)
self.title_fx.setOpacity(0.0)
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
self.toggle = QToolButton(self)
self.toggle.setCheckable(False)
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
self.toggle.clicked.connect(self.on_expand)
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
# To push the content up always
self._bottom_spacer = QtWidgets.QSpacerItem(
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
)
# Add core widgets to layout
self.content_layout.addWidget(self.toggle_row)
self.content_layout.addItem(self._bottom_spacer)
# Animations
self.width_anim = QPropertyAnimation(self, b"bar_width")
self.width_anim.setDuration(self._anim_duration)
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
self.title_anim.setDuration(self._anim_duration)
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
self.group = QParallelAnimationGroup(self)
self.group.addAnimation(self.width_anim)
self.group.addAnimation(self.title_anim)
self.group.finished.connect(self._on_anim_finished)
app = QtWidgets.QApplication.instance()
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
app.theme.theme_changed.connect(self._on_theme_changed)
@SafeProperty(int)
def bar_width(self) -> int:
"""
Get the current width of the side bar.
Returns:
int: The current width of the side bar.
"""
return self.width()
@bar_width.setter
def bar_width(self, width: int):
"""
Set the width of the side bar.
Args:
width(int): The new width of the side bar.
"""
self.setFixedWidth(width)
@SafeProperty(bool)
def is_expanded(self) -> bool:
"""
Check if the side bar is expanded.
Returns:
bool: True if the side bar is expanded, False otherwise.
"""
return self._is_expanded
@SafeSlot()
@SafeSlot(bool)
def on_expand(self):
"""
Toggle the expansion state of the side bar.
"""
self._is_expanded = not self._is_expanded
self.toggle.setIcon(
material_icon(
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
convert_to_pixmap=False,
)
)
if self._is_expanded:
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
self.group.stop()
# Setting limits for animations of the side bar
self.width_anim.setStartValue(self.width())
self.width_anim.setEndValue(
self._expanded_width if self._is_expanded else self._collapsed_width
)
self.title_anim.setStartValue(self.title_fx.opacity())
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
# Setting limits for animations of the components
for comp in self.components.values():
if hasattr(comp, "setup_animations"):
comp.setup_animations(self._is_expanded)
self.group.start()
if self._is_expanded:
# TODO do not like this trick, but it is what it is for now
self.title_label.setVisible(self._is_expanded)
for comp in self.components.values():
if hasattr(comp, "set_visible"):
comp.set_visible(self._is_expanded)
self.toggled.emit(self._is_expanded)
@SafeSlot()
def _on_anim_finished(self):
if not self._is_expanded:
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
# TODO do not like this trick, but it is what it is for now
self.title_label.setVisible(self._is_expanded)
for comp in self.components.values():
if hasattr(comp, "set_visible"):
comp.set_visible(self._is_expanded)
@SafeSlot(str)
def _on_theme_changed(self, theme_name: str):
# Refresh toggle arrow icon so it picks up the new theme
self.toggle.setIcon(
material_icon(
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
convert_to_pixmap=False,
)
)
# Refresh each component that supports it
for comp in self.components.values():
if hasattr(comp, "refresh_theme"):
comp.refresh_theme()
else:
comp.style().unpolish(comp)
comp.style().polish(comp)
comp.update()
self.style().unpolish(self)
self.style().polish(self)
self.update()
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
"""
Add a section header to the side bar.
Args:
title(str): The title of the section.
id(str): Unique ID for the section.
position(int, optional): Position to insert the section header.
Returns:
SectionHeader: The created section header.
"""
header = SectionHeader(self, title, anim_duration=self._anim_duration)
position = position if position is not None else self.content_layout.count() - 1
self.content_layout.insertWidget(position, header)
for anim in header.animations:
self.group.addAnimation(anim)
self.components[id] = header
return header
def add_separator(
self, *, from_top: bool = True, position: int | None = None
) -> SideBarSeparator:
"""
Add a separator line to the side bar. Separators are treated like regular
items; you can place multiple separators anywhere using `from_top` and `position`.
"""
line = SideBarSeparator(self)
line.setStyleSheet("margin:12px;")
self._insert_nav_item(line, from_top=from_top, position=position)
return line
def add_item(
self,
icon: str,
title: str,
id: str,
mini_text: str | None = None,
position: int | None = None,
*,
from_top: bool = True,
toggleable: bool = True,
exclusive: bool = True,
) -> NavigationItem:
"""
Add a navigation item to the side bar.
Args:
icon(str): Icon name for the nav item.
title(str): Title for the nav item.
id(str): Unique ID for the nav item.
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
position(int, optional): Position to insert the nav item.
from_top(bool, optional): Whether to count position from the top or bottom.
toggleable(bool, optional): Whether the nav item is toggleable.
exclusive(bool, optional): Whether the nav item is exclusive.
Returns:
NavigationItem: The created navigation item.
"""
item = NavigationItem(
parent=self,
title=title,
icon_name=icon,
mini_text=mini_text,
toggleable=toggleable,
exclusive=exclusive,
anim_duration=self._anim_duration,
)
self._insert_nav_item(item, from_top=from_top, position=position)
for anim in item.build_animations():
self.group.addAnimation(anim)
self.components[id] = item
# Connect activation to activation logic, passing id unchanged
item.activated.connect(lambda id=id: self.activate_item(id))
return item
def activate_item(self, target_id: str, *, emit_signal: bool = True):
target = self.components.get(target_id)
if target is None:
return
# Non-toggleable acts like an action: do not change any toggled states
if hasattr(target, "toggleable") and not target.toggleable:
self._active_id = target_id
if emit_signal:
self.view_selected.emit(target_id)
return
is_exclusive = getattr(target, "exclusive", True)
if is_exclusive:
# Radio-like behavior among exclusive items only
for comp_id, comp in self.components.items():
if not isinstance(comp, NavigationItem):
continue
if comp is target:
comp.set_active(True)
else:
# Only untoggle other items that are also exclusive
if getattr(comp, "exclusive", True):
comp.set_active(False)
# Leave non-exclusive items as they are
else:
# Non-exclusive toggles independently
target.set_active(not target.is_active())
self._active_id = target_id
if emit_signal:
self.view_selected.emit(target_id)
def add_dark_mode_item(
self, id: str = "dark_mode", position: int | None = None
) -> DarkModeNavItem:
"""
Add a dark mode toggle item to the side bar.
Args:
id(str): Unique ID for the dark mode item.
position(int, optional): Position to insert the dark mode item.
Returns:
DarkModeNavItem: The created dark mode navigation item.
"""
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
# compute bottom insertion point (same semantics as from_top=False)
self._insert_nav_item(item, from_top=False, position=position)
for anim in item.build_animations():
self.group.addAnimation(anim)
self.components[id] = item
item.activated.connect(lambda id=id: self.activate_item(id))
return item
def _insert_nav_item(
self, item: QWidget, *, from_top: bool = True, position: int | None = None
):
if from_top:
base_index = self.content_layout.indexOf(self._bottom_spacer)
pos = base_index if position is None else min(base_index, position)
else:
base = self.content_layout.indexOf(self._bottom_spacer) + 1
pos = base if position is None else base + max(0, position)
self.content_layout.insertWidget(pos, item)

View File

@@ -0,0 +1,372 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy import QtCore
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeProperty
from bec_widgets.applications.navigation_centre.reveal_animator import (
ANIMATION_DURATION,
RevealAnimator,
)
def get_on_primary():
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
return app.theme.color("ON_PRIMARY")
return "#FFFFFF"
def get_fg():
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
return app.theme.color("FG")
return "#FFFFFF"
class SideBarSeparator(QFrame):
"""A horizontal line separator for use in SideBar."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("SideBarSeparator")
self.setFrameShape(QFrame.NoFrame)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(2)
self.setProperty("variant", "separator")
class SectionHeader(QWidget):
"""A section header with a label and a horizontal line below."""
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
super().__init__(parent)
self.setObjectName("SectionHeader")
self.lbl = QLabel(text, self)
self.lbl.setObjectName("SectionHeaderLabel")
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
self.line = SideBarSeparator(self)
lay = QVBoxLayout(self)
# keep your margins/spacing preferences here if needed
lay.setContentsMargins(12, 0, 12, 0)
lay.setSpacing(6)
lay.addWidget(self.lbl)
lay.addWidget(self.line)
self.animations = self.build_animations()
def build_animations(self) -> list[QPropertyAnimation]:
"""
Build and return animations for expanding/collapsing the sidebar.
Returns:
list[QPropertyAnimation]: List of animations.
"""
return self._reveal.animations()
def setup_animations(self, expanded: bool):
"""
Setup animations for expanding/collapsing the sidebar.
Args:
expanded(bool): True if the sidebar is expanded, False if collapsed.
"""
self._reveal.setup(expanded)
class NavigationItem(QWidget):
"""A nav tile with an icon + labels and an optional expandable body.
Provides animations for collapsed/expanded sidebar states via
build_animations()/setup_animations(), similar to SectionHeader.
"""
activated = QtCore.Signal()
def __init__(
self,
parent=None,
*,
title: str,
icon_name: str,
mini_text: str | None = None,
toggleable: bool = True,
exclusive: bool = True,
anim_duration: int = ANIMATION_DURATION,
):
super().__init__(parent=parent)
self.setObjectName("NavigationItem")
# Private attributes
self._title = title
self._icon_name = icon_name
self._mini_text = mini_text or title
self._toggleable = toggleable
self._toggled = False
self._exclusive = exclusive
# Main Icon
self.icon_btn = QToolButton(self)
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
self.icon_btn.setAutoRaise(True)
self._icon_size_collapsed = QtCore.QSize(20, 20)
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(
"""
QToolButton:hover { background: transparent; border: none; }
QToolButton:pressed { background: transparent; border: none; }
"""
)
# Mini label below icon
self.mini_lbl = QLabel(self._mini_text, self)
self.mini_lbl.setObjectName("NavMiniLabel")
self.mini_lbl.setAlignment(Qt.AlignCenter)
self.mini_lbl.setStyleSheet("font-size: 10px;")
self.reveal_mini_lbl = RevealAnimator(
widget=self.mini_lbl,
initially_revealed=True,
animate_width=False,
duration=anim_duration,
)
# Container for icon + mini label
self.mini_icon = QWidget(self)
mini_lay = QVBoxLayout(self.mini_icon)
mini_lay.setContentsMargins(0, 2, 0, 2)
mini_lay.setSpacing(2)
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
# Title label
self.title_lbl = QLabel(self._title, self)
self.title_lbl.setObjectName("NavTitleLabel")
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.title_lbl.setStyleSheet("font-size: 13px;")
self.reveal_title_lbl = RevealAnimator(
widget=self.title_lbl,
initially_revealed=False,
animate_height=False,
duration=anim_duration,
)
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
lay = QHBoxLayout(self)
lay.setContentsMargins(12, 2, 12, 2)
lay.setSpacing(6)
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
self.icon_size_anim.setDuration(anim_duration)
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
# Connect icon button to emit activation
self.icon_btn.clicked.connect(self._emit_activated)
self.setMouseTracking(True)
self.setAttribute(Qt.WA_StyledBackground, True)
def is_active(self) -> bool:
"""Return whether the item is currently active/selected."""
return self.property("toggled") is True
def build_animations(self) -> list[QPropertyAnimation]:
"""
Build and return animations for expanding/collapsing the sidebar.
Returns:
list[QPropertyAnimation]: List of animations.
"""
return (
self.reveal_title_lbl.animations()
+ self.reveal_mini_lbl.animations()
+ [self.icon_size_anim]
)
def setup_animations(self, expanded: bool):
"""
Setup animations for expanding/collapsing the sidebar.
Args:
expanded(bool): True if the sidebar is expanded, False if collapsed.
"""
self.reveal_mini_lbl.setup(not expanded)
self.reveal_title_lbl.setup(expanded)
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
self.icon_size_anim.setEndValue(
self._icon_size_expanded if expanded else self._icon_size_collapsed
)
def set_visible(self, visible: bool):
"""Set visibility of the title label."""
self.title_lbl.setVisible(visible)
def _emit_activated(self):
self.activated.emit()
def set_active(self, active: bool):
"""
Set the active/selected state of the item.
Args:
active(bool): True to set active, False to deactivate.
"""
self.setProperty("toggled", active)
self.toggled = active
# ensure style refresh
self.style().unpolish(self)
self.style().polish(self)
self.update()
def mousePressEvent(self, event):
self.activated.emit()
super().mousePressEvent(event)
@SafeProperty(bool)
def toggleable(self) -> bool:
"""
Whether the item is toggleable (like a button) or not (like an action).
Returns:
bool: True if toggleable, False otherwise.
"""
return self._toggleable
@toggleable.setter
def toggleable(self, value: bool):
"""
Set whether the item is toggleable (like a button) or not (like an action).
Args:
value(bool): True to make toggleable, False otherwise.
"""
self._toggleable = bool(value)
@SafeProperty(bool)
def toggled(self) -> bool:
"""
Whether the item is currently toggled/selected.
Returns:
bool: True if toggled, False otherwise.
"""
return self._toggled
@toggled.setter
def toggled(self, value: bool):
"""
Set whether the item is currently toggled/selected.
Args:
value(bool): True to set toggled, False to untoggle.
"""
self._toggled = value
if value:
new_icon = material_icon(
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
)
else:
new_icon = material_icon(
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
)
self.icon_btn.setIcon(new_icon)
# Re-polish so QSS applies correct colors to icon/labels
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
w.style().unpolish(w)
w.style().polish(w)
w.update()
@SafeProperty(bool)
def exclusive(self) -> bool:
"""
Whether the item is exclusive in its toggle group.
Returns:
bool: True if exclusive, False otherwise.
"""
return self._exclusive
@exclusive.setter
def exclusive(self, value: bool):
"""
Set whether the item is exclusive in its toggle group.
Args:
value(bool): True to make exclusive, False otherwise.
"""
self._exclusive = bool(value)
def refresh_theme(self):
# Recompute icon/label colors according to current theme and state
# Trigger the toggled setter to rebuild the icon with the correct color
self.toggled = self._toggled
# Ensure QSS-driven text/icon colors refresh
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
w.style().unpolish(w)
w.style().polish(w)
w.update()
class DarkModeNavItem(NavigationItem):
"""Bottom action item that toggles app theme and updates its icon/text."""
def __init__(
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
):
super().__init__(
parent=parent,
title="Dark mode",
icon_name="dark_mode",
mini_text="Dark",
toggleable=False, # action-like, no selection highlight changes
exclusive=False,
anim_duration=anim_duration,
)
self._id = id
self._sync_from_qapp_theme()
self.activated.connect(self.toggle_theme)
def _qapp_dark_enabled(self) -> bool:
qapp = QApplication.instance()
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
def _sync_from_qapp_theme(self):
is_dark = self._qapp_dark_enabled()
# Update labels
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
self.mini_lbl.setText("Light" if is_dark else "Dark")
# Update icon
self.icon_btn.setIcon(
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
)
def refresh_theme(self):
self._sync_from_qapp_theme()
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
w.style().unpolish(w)
w.style().polish(w)
w.update()
def toggle_theme(self):
"""Toggle application theme and update icon/text."""
from bec_widgets.utils.colors import apply_theme
is_dark = self._qapp_dark_enabled()
apply_theme("light" if is_dark else "dark")
self._sync_from_qapp_theme()

View File

@@ -0,0 +1,60 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
from bec_widgets.applications.views.view import ViewBase
class DeveloperView(ViewBase):
"""
A view for users to write scripts and macros and execute them within the application.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.developer_widget = DeveloperWidget(parent=self)
self.set_content(self.developer_widget)
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()
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# 70% of screen height, keep 16:9 ratio
height = int(screen_height * 0.9)
width = int(height * (16 / 9))
# If width exceeds screen width, scale down
if width > screen_width * 0.9:
width = int(screen_width * 0.9)
height = int(width / (16 / 9))
_app.resize(width, height)
developer_view = DeveloperView()
_app.add_view(
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
)
_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

@@ -0,0 +1,423 @@
from __future__ import annotations
import re
import markdown
from bec_lib.endpoints import MessageEndpoints
from bec_lib.script_executor import upload_script
from bec_qthemes import material_icon
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import QTextEdit
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.qt_ads import CDockWidget
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
def markdown_to_html(md_text: str) -> str:
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
# Preprocess: convert consecutive >>> lines to Python code blocks
def replace_python_examples(match):
indent = match.group(1)
examples = match.group(2)
# Remove >>> prefix and clean up the code
lines = []
for line in examples.strip().split("\n"):
line = line.strip()
if line.startswith(">>> "):
lines.append(line[4:]) # Remove '>>> '
elif line.startswith(">>>"):
lines.append(line[3:]) # Remove '>>>'
code = "\n".join(lines)
return f"{indent}```python\n{indent}{code}\n{indent}```"
# Match one or more consecutive >>> lines (with same indentation)
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
html = markdown.markdown(
md_text,
extensions=extensions,
extension_configs={
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
},
output_format="html",
)
# Remove hardcoded background colors that conflict with themes
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
html = re.sub(r"background: #[^;]*;", "", html)
# Add CSS to force code blocks to wrap
css = """
<style>
pre, code {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.codehilite pre {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
</style>
"""
return css + html
class DeveloperWidget(DockAreaWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, variant="compact", **kwargs)
# Promote toolbar above the dock manager provided by the base class
self.toolbar = ModularToolBar(self)
self.init_developer_toolbar()
self._root_layout.insertWidget(0, self.toolbar)
# 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.terminal.setObjectName("Terminal")
self.monaco = MonacoDock(self)
self.monaco.setObjectName("MonacoEditor")
self.monaco.save_enabled.connect(self._on_save_enabled_update)
self.plotting_ads = AdvancedDockArea(
self,
mode="plot",
default_add_direction="bottom",
profile_namespace="developer_plotting",
auto_profile_namespace=False,
enable_profile_management=False,
variant="compact",
)
self.plotting_ads.setObjectName("PlottingArea")
self.signature_help = QTextEdit(self)
self.signature_help.setObjectName("Signature Help")
self.signature_help.setAcceptRichText(True)
self.signature_help.setReadOnly(True)
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
opt = self.signature_help.document().defaultTextOption()
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
self.signature_help.document().setDefaultTextOption(opt)
self.monaco.signature_help.connect(
lambda text: self.signature_help.setHtml(markdown_to_html(text))
)
self._current_script_id: str | None = None
self.script_editor_tab = None
self._initialize_layout()
# 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"])
def _initialize_layout(self) -> None:
"""Create the default dock arrangement for the developer workspace."""
# Monaco editor as the central dock
self.monaco_dock = self.new(
self.monaco,
closable=False,
floatable=False,
movable=False,
return_dock=True,
show_title_bar=False,
show_settings_action=False,
title_buttons={"float": False, "close": False, "menu": False},
# promote_central=True,
)
# Explorer on the left without a title bar
self.explorer_dock = self.new(
self.explorer,
where="left",
closable=False,
floatable=False,
movable=False,
return_dock=True,
show_title_bar=False,
)
# Console and terminal tabbed along the bottom
self.console_dock = self.new(
self.console,
relative_to=self.monaco_dock,
where="bottom",
closable=False,
floatable=False,
movable=False,
return_dock=True,
title_buttons={"float": True, "close": False},
)
self.terminal_dock = self.new(
self.terminal,
closable=False,
floatable=False,
movable=False,
tab_with=self.console_dock,
return_dock=True,
title_buttons={"float": False, "close": False},
)
# Plotting area on the right with signature help tabbed alongside
self.plotting_ads_dock = self.new(
self.plotting_ads,
where="right",
closable=False,
floatable=False,
movable=False,
return_dock=True,
title_buttons={"float": True},
)
self.signature_dock = self.new(
self.signature_help,
closable=False,
floatable=False,
movable=False,
tab_with=self.plotting_ads_dock,
return_dock=True,
title_buttons={"float": False, "close": False},
)
self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3])
def init_developer_toolbar(self):
"""Initialize the developer toolbar with necessary actions and widgets."""
save_button = MaterialIconAction(
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
)
save_button.action.triggered.connect(self.on_save)
self.toolbar.components.add_safe("save", save_button)
save_as_button = MaterialIconAction(
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
)
self.toolbar.components.add_safe("save_as", save_as_button)
save_as_button.action.triggered.connect(self.on_save_as)
save_bundle = ToolbarBundle("save", self.toolbar.components)
save_bundle.add_action("save")
save_bundle.add_action("save_as")
self.toolbar.add_bundle(save_bundle)
run_action = MaterialIconAction(
icon_name="play_arrow",
tooltip="Run current file",
label_text="Run",
filled=True,
parent=self,
)
run_action.action.triggered.connect(self.on_execute)
self.toolbar.components.add_safe("run", run_action)
stop_action = MaterialIconAction(
icon_name="stop",
tooltip="Stop current execution",
label_text="Stop",
filled=True,
parent=self,
)
stop_action.action.triggered.connect(self.on_stop)
self.toolbar.components.add_safe("stop", stop_action)
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
execution_bundle.add_action("run")
execution_bundle.add_action("stop")
self.toolbar.add_bundle(execution_bundle)
vim_action = MaterialIconAction(
icon_name="vim",
tooltip="Toggle Vim Mode",
label_text="Vim",
filled=True,
parent=self,
checkable=True,
)
self.toolbar.components.add_safe("vim", vim_action)
vim_action.action.triggered.connect(self.on_vim_triggered)
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
settings_bundle.add_action("vim")
self.toolbar.add_bundle(settings_bundle)
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
save_shortcut.activated.connect(self.on_save)
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
save_as_shortcut.activated.connect(self.on_save_as)
def _open_new_file(self, file_name: str, scope: str):
self.monaco.open_file(file_name, scope)
# Set read-only mode for shared files
if "shared" in scope:
self.monaco.set_file_readonly(file_name, True)
# Add appropriate icon based on file type
if "script" in scope:
# Use script icon for script files
icon = material_icon("script", size=(24, 24))
self.monaco.set_file_icon(file_name, icon)
elif "macro" in scope:
# Use function icon for macro files
icon = material_icon("function", size=(24, 24))
self.monaco.set_file_icon(file_name, icon)
@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)
def _on_save_enabled_update(self, enabled: bool):
self.toolbar.components.get_action("save").action.setEnabled(enabled)
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
@SafeSlot()
def on_execute(self):
"""Upload and run the currently focused script in the Monaco editor."""
self.script_editor_tab = self.monaco.last_focused_editor
if not self.script_editor_tab:
return
widget = self.script_editor_tab.widget()
if not isinstance(widget, MonacoWidget):
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
self._current_script_id = value
self._update_subscription(value, old_script_id)
def _update_subscription(self, new_script_id: str | None, old_script_id: str | None):
if old_script_id is not None:
self.bec_dispatcher.disconnect_slot(
self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id)
)
if new_script_id is not None:
self.bec_dispatcher.connect_slot(
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 button 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):
"""
Handle script execution info messages to update the editor highlights.
Args:
content (dict): The content of the message containing execution info.
metadata (dict): Additional metadata for the message.
"""
print(f"Script execution info: {content}")
current_lines = content.get("current_lines")
if self.script_editor_tab is None:
return
widget = self.script_editor_tab.widget()
if not isinstance(widget, MonacoWidget):
return
if not current_lines:
widget.clear_highlighted_lines()
return
line_number = current_lines[0]
widget.clear_highlighted_lines()
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

@@ -0,0 +1,686 @@
from __future__ import annotations
import os
from functools import partial
from typing import List, Literal
import yaml
from bec_lib import config_helper
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from qtpy.QtCore import Qt, QThreadPool, QTimer
from qtpy.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QTextEdit,
QVBoxLayout,
QWidget,
)
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
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.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
AvailableDeviceResources,
)
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
PresetClassDeviceConfigDialog,
)
logger = bec_logger.logger
_yes_no_question = partial(
QMessageBox.question,
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
defaultButton=QMessageBox.StandardButton.No,
)
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.
"""
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)
class ConfigChoiceDialog(QDialog):
REPLACE = 1
ADD = 2
CANCEL = 0
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Load Config")
layout = QVBoxLayout(self)
label = QLabel("Do you want to replace the current config or add to it?")
label.setWordWrap(True)
layout.addWidget(label)
# Buttons: equal size, stacked vertically
self.replace_btn = QPushButton("Replace")
self.add_btn = QPushButton("Add")
self.cancel_btn = QPushButton("Cancel")
btn_layout = QHBoxLayout()
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
# Connect signals to explicit slots
self.replace_btn.clicked.connect(self.accept_replace)
self.add_btn.clicked.connect(self.accept_add)
self.cancel_btn.clicked.connect(self.reject_cancel)
self._result = self.CANCEL
def accept_replace(self):
self._result = self.REPLACE
self.accept()
def accept_add(self):
self._result = self.ADD
self.accept()
def reject_cancel(self):
self._result = self.CANCEL
self.reject()
def result(self):
return self._result
AVAILABLE_RESOURCE_IS_READY = False
class DeviceManagerView(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, client=None, *args, **kwargs)
self._config_helper = config_helper.ConfigHelper(self.client.connector)
self._shared_selection = SharedSelectionSignal()
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.dock_manager = QtAds.CDockManager(self)
self.dock_manager.setStyleSheet("")
self._root_layout.addWidget(self.dock_manager)
# Device Table View widget
self.device_table_view = DeviceTableView(
self, shared_selection_signal=self._shared_selection
)
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
# Device Config View widget
self.dm_config_view = DMConfigView(self)
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Docstring View
self.dm_docs_view = DocstringView(self)
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
# Ophyd Test view
self.ophyd_test_view = DMOphydTest(self)
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
# Help Inspector
widget = QWidget(self)
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.help_inspector = HelpInspector(self)
layout.addWidget(self.help_inspector)
text_box = QTextEdit(self)
text_box.setReadOnly(False)
text_box.setPlaceholderText("Help text will appear here...")
layout.addWidget(text_box)
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
self.help_inspector_dock.setWidget(widget)
# Register callback
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
# Error Logs View
self.error_logs_view = QTextEdit(self)
self.error_logs_view.setReadOnly(True)
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
self.error_logs_dock.setWidget(self.error_logs_view)
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
# Arrange widgets within the QtAds dock manager
# Central widget area
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
# Right area - should be pushed into view if something is active
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea,
self.ophyd_test_dock_view,
self.central_dock_area,
)
# create bottom area (2-arg -> area)
self.bottom_dock_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
)
# YAML view left of docstrings (docks relative to bottom area)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
)
# Error/help area right of docstrings (dock relative to bottom area)
area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea,
self.help_inspector_dock,
self.bottom_dock_area,
)
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
for dock in self.dock_manager.dockWidgets():
dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False)
dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False)
dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False)
# Apply stretch after the layout is done
self.set_default_view([2, 8, 2], [7, 3])
for signal, slots in [
(
self.device_table_view.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.ophyd_test_view.device_validated,
(self.device_table_view.update_device_validation,),
),
(
self.device_table_view.device_configs_changed,
(self.ophyd_test_view.change_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
# Once available resource is ready, add it to the view again
if AVAILABLE_RESOURCE_IS_READY:
# Available Resources Widget
self.available_devices = AvailableDeviceResources(
self, shared_selection_signal=self._shared_selection
)
self.available_devices_dock = QtAds.CDockWidget(
self.dock_manager, "Available Devices", self
)
self.available_devices_dock.setWidget(self.available_devices)
# Connect slots for available reosource
for signal, slots in [
(
self.available_devices.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.device_table_view.device_configs_changed,
(self.available_devices.mark_devices_used,),
),
(
self.available_devices.add_selected_devices,
(self.device_table_view.add_device_configs,),
),
(
self.available_devices.del_selected_devices,
(self.device_table_view.remove_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
# Add toolbar
self._add_toolbar()
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
# Add IO actions
self._add_io_actions()
self._add_table_actions()
self.toolbar.show_bundles(["IO", "Table"])
self._root_layout.insertWidget(0, self.toolbar)
def _add_io_actions(self):
# Create IO bundle
io_bundle = ToolbarBundle("IO", self.toolbar.components)
# Load from disk
load = MaterialIconAction(
text_position="under",
icon_name="file_open",
parent=self,
tooltip="Load configuration file from disk",
label_text="Load Config",
)
self.toolbar.components.add_safe("load", load)
load.action.triggered.connect(self._load_file_action)
io_bundle.add_action("load")
# Add safe to disk
save_to_disk = MaterialIconAction(
text_position="under",
icon_name="file_save",
parent=self,
tooltip="Save config to disk",
label_text="Save Config",
)
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
save_to_disk.action.triggered.connect(self._save_to_disk_action)
io_bundle.add_action("save_to_disk")
# Add load config from redis
load_redis = MaterialIconAction(
text_position="under",
icon_name="cached",
parent=self,
tooltip="Load current config from Redis",
label_text="Get Current Config",
)
load_redis.action.triggered.connect(self._load_redis_action)
self.toolbar.components.add_safe("load_redis", load_redis)
io_bundle.add_action("load_redis")
# Update config action
update_config_redis = MaterialIconAction(
text_position="under",
icon_name="cloud_upload",
parent=self,
tooltip="Update current config in Redis",
label_text="Update Config",
)
update_config_redis.action.setEnabled(False)
update_config_redis.action.triggered.connect(self._update_redis_action)
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
io_bundle.add_action("update_config_redis")
# Add load config from plugin dir
self.toolbar.add_bundle(io_bundle)
# Table actions
def _add_table_actions(self) -> None:
table_bundle = ToolbarBundle("Table", self.toolbar.components)
# Reset composed view
reset_composed = MaterialIconAction(
text_position="under",
icon_name="delete_sweep",
parent=self,
tooltip="Reset current composed config view",
label_text="Reset Config",
)
reset_composed.action.triggered.connect(self._reset_composed_view)
self.toolbar.components.add_safe("reset_composed", reset_composed)
table_bundle.add_action("reset_composed")
# Add device
add_device = MaterialIconAction(
text_position="under",
icon_name="add",
parent=self,
tooltip="Add new device",
label_text="Add Device",
)
add_device.action.triggered.connect(self._add_device_action)
self.toolbar.components.add_safe("add_device", add_device)
table_bundle.add_action("add_device")
# Remove device
remove_device = MaterialIconAction(
text_position="under",
icon_name="remove",
parent=self,
tooltip="Remove device",
label_text="Remove Device",
)
remove_device.action.triggered.connect(self._remove_device_action)
self.toolbar.components.add_safe("remove_device", remove_device)
table_bundle.add_action("remove_device")
# Rerun validation
rerun_validation = MaterialIconAction(
text_position="under",
icon_name="checklist",
parent=self,
tooltip="Run device validation with 'connect' on selected devices",
label_text="Validate Connection",
)
rerun_validation.action.triggered.connect(self._rerun_validation_action)
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
table_bundle.add_action("rerun_validation")
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
# IO actions
def _coming_soon(self):
return QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
try:
plugin_path = plugin_repo_path()
plugin_name = plugin_package_name()
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
except ValueError:
# Get the recovery config path as fallback
config_path = self._get_recovery_config_path()
logger.warning(
f"No plugin repository installed, fallback to recovery config path: {config_path}"
)
# Implement the file loading logic here
start_dir = os.path.abspath(config_path)
file_path = self._get_file_path(start_dir, "open_file")
if file_path:
self._load_config_from_file(file_path)
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
if mode == "open_file":
file_path, _ = QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
else:
file_path, _ = QFileDialog.getSaveFileName(
self, caption="Save Config File", dir=start_dir
)
return file_path
def _load_config_from_file(self, file_path: str):
"""
Load device config from a given file path and update the device table view.
Args:
file_path (str): Path to the configuration file.
"""
try:
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
self._open_config_choice_dialog(config)
def _open_config_choice_dialog(self, config: List[dict]):
"""
Open a dialog to choose whether to replace or add the loaded config.
Args:
config (List[dict]): List of device configurations loaded from the file.
"""
dialog = ConfigChoiceDialog(self)
if dialog.exec():
if dialog.result() == ConfigChoiceDialog.REPLACE:
self.device_table_view.set_device_config(config)
elif dialog.result() == ConfigChoiceDialog.ADD:
self.device_table_view.add_device_configs(config)
# TODO would we ever like to add the current config to an existing composition
@SafeSlot()
def _load_redis_action(self):
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
reply = _yes_no_question(
self,
"Load currently active config",
"Do you really want to discard the current config and reload?",
)
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
self.device_table_view.set_device_config(
self.client.device_manager._get_redis_device_config()
)
else:
return
@SafeSlot()
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
"""Action to push the current composition to Redis"""
reply = _yes_no_question(
self,
"Push composition to Redis",
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
)
if reply != QMessageBox.StandardButton.Yes:
return
if self.device_table_view.table.contains_invalid_devices():
return QMessageBox.warning(
self, "Validation has errors!", "Please resolve before proceeding."
)
if self.ophyd_test_view.validation_running():
return QMessageBox.warning(
self, "Validation has not completed.", "Please wait for the validation to finish."
)
self._push_composition_to_redis()
def _push_composition_to_redis(self):
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
threadpool = QThreadPool.globalInstance()
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
threadpool.start(comm)
@SafeSlot()
def _save_to_disk_action(self):
"""Action for the 'save_to_disk' action to save the current config to disk."""
# Check if plugin repo is installed...
try:
config_path = self._get_recovery_config_path()
except ValueError:
# Get the recovery config path as fallback
config_path = os.path.abspath(os.path.expanduser("~"))
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
# Implement the file loading logic here
file_path = self._get_file_path(config_path, "save_file")
if file_path:
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
with open(file_path, "w") as file:
file.write(yaml.dump(config))
# Table actions
@SafeSlot()
def _reset_composed_view(self):
"""Action for the 'reset_composed_view' action to reset the composed view."""
reply = _yes_no_question(
self,
"Clear View",
"You are about to clear the current composed config view, please confirm...",
)
if reply == QMessageBox.StandardButton.Yes:
self.device_table_view.clear_device_configs()
# TODO Bespoke Form to add a new device
@SafeSlot()
def _add_device_action(self):
"""Action for the 'add_device' action to add a new device."""
dialog = PresetClassDeviceConfigDialog(parent=self)
dialog.accepted_data.connect(self._add_to_table_from_dialog)
dialog.open()
@SafeSlot(dict)
def _add_to_table_from_dialog(self, data):
self.device_table_view.add_device_configs([data])
@SafeSlot()
def _remove_device_action(self):
"""Action for the 'remove_device' action to remove a device."""
self.device_table_view.remove_selected_rows()
@SafeSlot()
@SafeSlot(bool)
def _rerun_validation_action(self, connect: bool = True):
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
configs = self.device_table_view.table.selected_configs()
self.ophyd_test_view.change_device_configs(configs, True, connect)
####### Default view has to be done with setting up splitters ########
def set_default_view(
self, horizontal_weights: list, vertical_weights: list
): # TODO separate logic for all ads based widgets
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
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
): # TODO separate logic for all ads based widgets
"""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)
def _get_recovery_config_path(self) -> str:
"""Get the recovery config path from the log_writer config."""
# pylint: disable=protected-access
log_writer_config = self.client._service_config.config.get("log_writer", {})
writer = DeviceConfigWriter(service_config=log_writer_config)
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
if __name__ == "__main__":
import sys
from copy import deepcopy
from bec_lib.bec_yaml_loader import yaml_load
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
w = QWidget()
l = QVBoxLayout()
w.setLayout(l)
apply_theme("dark")
button = DarkModeButton()
l.addWidget(button)
device_manager_view = DeviceManagerView()
l.addWidget(device_manager_view)
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
# cfg = yaml_load(config_path)
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
# # config = device_manager_view.client.device_manager._get_redis_device_config()
# device_manager_view.device_table_view.set_device_config(cfg)
w.show()
w.setWindowTitle("Device Manager View")
w.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

@@ -0,0 +1,119 @@
"""Top Level wrapper for device_manager widget"""
from __future__ import annotations
import os
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
self.setLayout(self.stacked_layout)
# Add device manager view
self.device_manager_view = DeviceManagerView()
self.stacked_layout.addWidget(self.device_manager_view)
# Add overlay widget
self._overlay_widget = QtWidgets.QWidget(self)
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_overlay(self):
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
# Load current config
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
self.button_load_current_config.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_current_config)
self.button_load_current_config.clicked.connect(self._load_config_clicked)
# Load config from disk
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
self.button_load_config_from_file.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_config_from_file)
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
self._overlay_widget.setVisible(True)
def _load_config_from_file_clicked(self):
"""Handle click on 'Load Config From File' button."""
start_dir = os.path.expanduser("~")
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
self._load_config_from_file(file_path)
def _load_config_from_file(self, file_path: str):
try:
config = yaml_load(file_path)
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
config_list = []
for name, cfg in config.items():
config_list.append(cfg)
config_list[-1]["name"] = name
self.device_manager_view.device_table_view.set_device_config(config_list)
# self.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
@SafeSlot()
def _load_config_clicked(self):
"""Handle click on 'Load Current Config' button."""
config = self.client.device_manager._get_redis_device_config()
self.device_manager_view.device_table_view.set_device_config(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
from bec_widgets.utils.colors import apply_theme
apply_theme("light")
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
device_manager = DeviceManagerWidget()
# config = device_manager.client.device_manager._get_redis_device_config()
# device_manager.device_table_view.set_device_config(config)
layout.addWidget(device_manager)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
dark_mode_button = DarkModeButton()
layout.addWidget(dark_mode_button)
widget.show()
device_manager.setWindowTitle("Device Manager View")
device_manager.resize(1600, 1200)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,363 @@
from __future__ import annotations
from typing import List
from qtpy.QtCore import QEventLoop, Qt, QTimer
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSplitter,
QStackedLayout,
QVBoxLayout,
QWidget,
)
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.
"""
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)
class ViewBase(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.
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.
title (str | None): Optional human-readable title.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent)
self.content: QWidget | None = None
self.view_id = id
self.view_title = title
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
if content is not None:
self.set_content(content)
def set_content(self, content: QWidget) -> None:
"""Replace the current content widget with a new one."""
if self.content is not None:
self.content.setParent(None)
self.content = content
self.layout().addWidget(content)
@SafeSlot()
def on_enter(self) -> None:
"""Called after the view becomes current/visible.
Default implementation does nothing. Override in subclasses.
"""
pass
@SafeSlot()
def on_exit(self) -> bool:
"""Called before the view is switched away/hidden.
Return True to allow switching, or False to veto.
Default implementation allows switching.
"""
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.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
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)
####################################################################################################
# Example views for demonstration/testing purposes
####################################################################################################
# --- Popup UI version ---
class WaveformViewPopup(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.waveform = Waveform(parent=self)
self.set_content(self.waveform)
@SafeSlot()
def on_enter(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("Configure Waveform View")
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
# same as in the CurveRow used in waveform
self.device_edit = DeviceComboBox(parent=self)
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)
form = QFormLayout()
form.addRow(label)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
v = QVBoxLayout(dialog)
v.addLayout(form)
v.addWidget(buttons)
if dialog.exec_() == QDialog.Accepted:
self.waveform.plot(
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
)
@SafeSlot()
def on_exit(self) -> bool:
ans = QMessageBox.question(
self,
"Switch and clear?",
"Do you want to switch views and clear the plot?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans == QMessageBox.Yes:
self.waveform.clear_all()
return True
return False
# --- Inline stacked UI version ---
class WaveformViewInline(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# Root layout for this view uses a stacked layout
self.stack = QStackedLayout()
container = QWidget(self)
container.setLayout(self.stack)
self.set_content(container)
# --- Page 0: Settings page (inline form)
self.settings_page = QWidget()
sp_layout = QVBoxLayout(self.settings_page)
sp_layout.setContentsMargins(16, 16, 16, 16)
sp_layout.setSpacing(12)
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
self.device_edit = DeviceComboBox(parent=self.settings_page)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self.settings_page)
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)
form = QFormLayout()
form.addRow(title)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
btn_row = QHBoxLayout()
ok_btn = QPushButton("OK", parent=self.settings_page)
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
btn_row.addStretch(1)
btn_row.addWidget(cancel_btn)
btn_row.addWidget(ok_btn)
sp_layout.addLayout(form)
sp_layout.addLayout(btn_row)
# --- Page 1: Waveform page
self.waveform_page = QWidget()
wf_layout = QVBoxLayout(self.waveform_page)
wf_layout.setContentsMargins(0, 0, 0, 0)
self.waveform = Waveform(parent=self.waveform_page)
wf_layout.addWidget(self.waveform)
# --- Page 2: Exit confirmation page (inline)
self.confirm_page = QWidget()
cp_layout = QVBoxLayout(self.confirm_page)
cp_layout.setContentsMargins(16, 16, 16, 16)
cp_layout.setSpacing(12)
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
cp_buttons = QHBoxLayout()
no_btn = QPushButton("No", parent=self.confirm_page)
yes_btn = QPushButton("Yes", parent=self.confirm_page)
cp_buttons.addStretch(1)
cp_buttons.addWidget(no_btn)
cp_buttons.addWidget(yes_btn)
cp_layout.addWidget(qlabel)
cp_layout.addLayout(cp_buttons)
# Add pages to the stack
self.stack.addWidget(self.settings_page) # index 0
self.stack.addWidget(self.waveform_page) # index 1
self.stack.addWidget(self.confirm_page) # index 2
# Wire settings buttons
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
cancel_btn.clicked.connect(self._show_waveform_without_changes)
# Prepare result holder for the inline confirmation
self._exit_choice_yes = None
yes_btn.clicked.connect(lambda: self._exit_reply(True))
no_btn.clicked.connect(lambda: self._exit_reply(False))
@SafeSlot()
def on_enter(self) -> None:
# Always start on the settings page when entering
self.stack.setCurrentIndex(0)
@SafeSlot()
def on_exit(self) -> bool:
# Show inline confirmation page and synchronously wait for a choice
# -> trick to make the choice blocking, however popup would be cleaner solution
self._exit_choice_yes = None
self.stack.setCurrentIndex(2)
loop = QEventLoop()
self._exit_loop = loop
loop.exec_()
if self._exit_choice_yes:
self.waveform.clear_all()
return True
# Revert to waveform view if user cancelled switching
self.stack.setCurrentIndex(1)
return False
def _apply_settings_and_show_waveform(self):
dev = self.device_edit.currentText()
sig = self.entry_edit.currentText()
if dev and sig:
self.waveform.plot(y_name=dev, y_entry=sig)
self.stack.setCurrentIndex(1)
def _show_waveform_without_changes(self):
# Just show waveform page without plotting
self.stack.setCurrentIndex(1)
def _exit_reply(self, yes: bool):
self._exit_choice_yes = bool(yes)
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
self._exit_loop.quit()

File diff suppressed because it is too large Load Diff

View File

@@ -14,18 +14,21 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
logger = bec_logger.logger
@@ -151,8 +154,10 @@ def wait_for_server(client: BECGuiClient):
raise RuntimeError("GUI is not alive")
try:
if client._gui_started_event.wait(timeout=timeout):
client._gui_started_timer.cancel()
client._gui_started_timer.join()
if client._gui_started_timer is not None:
# cancel the timer, we are done
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
@@ -261,18 +266,37 @@ class BECGuiClient(RPCBase):
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
return self._start(wait=wait)
def show(self):
"""Show the GUI window."""
def show(self, wait=True) -> None:
"""
Show the GUI window.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive():
return self._show_all()
return self.start(wait=True)
return self._start(wait=wait)
def hide(self):
"""Hide the GUI window."""
return self._hide_all()
def raise_window(self, wait: bool = True) -> None:
"""
Bring GUI windows to the front.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive():
return self._raise_all()
return self._start(wait=wait)
def new(
self,
name: str | None = None,
@@ -382,6 +406,9 @@ class BECGuiClient(RPCBase):
"""
Start the GUI server, and execute callback when it is launched
"""
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
@@ -428,8 +455,8 @@ class BECGuiClient(RPCBase):
self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -439,11 +466,24 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
if not self._killed:
for window in self._top_level.values():
window.hide()
if self._killed:
return
self.launcher._run_rpc("hide")
for window in self._top_level.values():
window.hide()
def _do_raise_all(self):
"""Bring GUI windows to the front."""
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("raise") # pylint: disable=protected-access
for window in self._top_level.values():
window._run_rpc("raise") # type: ignore[attr-defined]
def _raise_all(self):
with wait_for_server(self):
if self._killed:
return
return self._do_raise_all()
def _update_dynamic_namespace(self, server_registry: dict):
"""
@@ -524,7 +564,7 @@ if __name__ == "__main__": # pragma: no cover
# Test the client_utils.py module
gui = BECGuiClient()
gui.start(wait=True)
gui.show(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:

View File

@@ -53,7 +53,7 @@ from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -180,7 +180,10 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
@@ -205,14 +208,26 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ...
"""
self.content += """
@rpc_call"""
self.content += f"""
{self._rpc_call(timeout)}"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.

View File

@@ -39,6 +39,29 @@ def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.
@@ -179,6 +202,11 @@ class RPCBase:
parent = parent._parent
return parent # type: ignore
def raise_window(self):
"""Bring this widget (or its container) to the front."""
# Use explicit call to ensure action name is 'raise' (not 'raise_')
return self._run_rpc("raise")
def _run_rpc(
self,
method,
@@ -202,6 +230,12 @@ class RPCBase:
Returns:
The result of the RPC call.
"""
if method in ["show", "hide", "raise"] and gui_id is None:
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
gui_id = obj.get("container_proxy") # type: ignore
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,

View File

@@ -7,8 +7,10 @@ import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
import darkdetect
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_qthemes import apply_theme
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
@@ -92,6 +94,11 @@ class GUIServer:
Run the GUI server.
"""
self.app = QApplication(sys.argv)
if darkdetect.isDark():
apply_theme("dark")
else:
apply_theme("light")
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.setup_bec_icon()

View File

@@ -1,12 +1,23 @@
from __future__ import annotations
import ast
import importlib
import os
from typing import Any, Dict
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSplitter,
QTabWidget,
@@ -14,148 +25,359 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils import BECDispatcher
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.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
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.plot_base import PlotBase
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access.
def __init__(self, parent=None):
super().__init__(parent)
Features:
- Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`.
- Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`.
- Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut.
- Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block.
"""
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self._widgets_by_name: Dict[str, QWidget] = {}
self._init_ui()
# console push
# expose helper API and basics in the inprocess console
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"pg": pg,
"wh": wh,
"dock": self.dock,
"im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
# "btn2": self.btn2,
# "btn3": self.btn3,
# "btn4": self.btn4,
# "btn5": self.btn5,
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
}
)
# A thin API wrapper so users have a stable, minimal surface in the console
class _ConsoleAPI:
def __init__(self, win: "JupyterConsoleWindow"):
self._win = win
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None):
"""Add an existing QWidget as a new tab and expose it in the console under `shortcut`."""
return self._win.add_widget(widget, shortcut, title=title)
def add_widget_by_class_path(
self,
class_path: str,
shortcut: str,
kwargs: dict | None = None,
title: str | None = None,
):
"""Import a QWidget class from `class_path`, instantiate it, and add it."""
return self._win.add_widget_by_class_path(
class_path, shortcut, kwargs=kwargs, title=title
)
def add_widget_by_type(
self,
widget_type: str,
shortcut: str,
kwargs: dict | None = None,
title: str | None = None,
):
"""Instantiate a registered BEC widget by type string and add it."""
return self._win.add_widget_by_type(
widget_type, shortcut, kwargs=kwargs, title=title
)
def list_widgets(self):
return list(self._win._widgets_by_name.keys())
def get_widget(self, shortcut: str) -> QWidget | None:
return self._win._widgets_by_name.get(shortcut)
def available_widgets(self):
return list(widget_handler.widget_classes.keys())
self.jc = _ConsoleAPI(self)
self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh})
def _init_ui(self):
self.layout = QHBoxLayout(self)
# Horizontal splitter
# Horizontal splitter: left = widgets tabs, right = console + add-widget panel
splitter = QSplitter(self)
self.layout.addWidget(splitter)
tab_widget = QTabWidget(splitter)
# Left: tabs that will host dynamically added widgets
self.tab_widget = QTabWidget(splitter)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
# Right: console area with an add-widget mini panel on top
right_panel = QGroupBox("Jupyter Console", splitter)
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(6, 12, 6, 6)
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
# self.lm = LayoutManagerWidget()
# third_tab_layout.addWidget(self.lm)
# tab_widget.addTab(third_tab, "Layout Manager Widget")
#
# fourth_tab = QWidget()
# fourth_tab_layout = QVBoxLayout(fourth_tab)
# self.pb = PlotBase()
# self.pi = self.pb.plot_item
# fourth_tab_layout.addWidget(self.pb)
# tab_widget.addTab(fourth_tab, "PlotBase")
#
# tab_widget.setCurrentIndex(3)
#
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
# Add-widget mini panel
add_panel = QFrame(right_panel)
shape = QFrame.Shape.StyledPanel # PySide6 style enums
add_panel.setFrameShape(shape)
add_grid = QGridLayout(add_panel)
add_grid.setContentsMargins(8, 8, 8, 8)
add_grid.setHorizontalSpacing(8)
add_grid.setVerticalSpacing(6)
instr = QLabel(
"Add a widget by class path or choose a registered BEC widget type,"
" and expose it in the console under a shortcut.\n"
"Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform"
)
instr.setWordWrap(True)
add_grid.addWidget(instr, 0, 0, 1, 2)
# Registered widget selector
reg_label = QLabel("Registered")
reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.registry_combo = QComboBox(add_panel)
self.registry_combo.setEditable(False)
self.refresh_btn = QPushButton("Refresh")
reg_row = QHBoxLayout()
reg_row.addWidget(self.registry_combo)
reg_row.addWidget(self.refresh_btn)
add_grid.addWidget(reg_label, 1, 0)
add_grid.addLayout(reg_row, 1, 1)
# Class path entry
class_label = QLabel("Class")
class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.class_path_edit = QLineEdit(add_panel)
self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)")
add_grid.addWidget(class_label, 2, 0)
add_grid.addWidget(self.class_path_edit, 2, 1)
# Shortcut
shortcut_label = QLabel("Shortcut")
shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.shortcut_edit = QLineEdit(add_panel)
self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)")
add_grid.addWidget(shortcut_label, 3, 0)
add_grid.addWidget(self.shortcut_edit, 3, 1)
# Kwargs
kwargs_label = QLabel("Kwargs")
kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.kwargs_edit = QLineEdit(add_panel)
self.kwargs_edit.setPlaceholderText(
'Optional kwargs as dict literal, e.g. {"popups": True}'
)
add_grid.addWidget(kwargs_label, 4, 0)
add_grid.addWidget(self.kwargs_edit, 4, 1)
# Title
title_label = QLabel("Title")
title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.title_edit = QLineEdit(add_panel)
self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)")
add_grid.addWidget(title_label, 5, 0)
add_grid.addWidget(self.title_edit, 5, 1)
# Buttons
btn_row = QHBoxLayout()
self.add_btn = QPushButton("Add by class path")
self.add_btn.clicked.connect(self._on_add_widget_clicked)
self.add_reg_btn = QPushButton("Add registered")
self.add_reg_btn.clicked.connect(self._on_add_registered_clicked)
btn_row.addStretch(1)
btn_row.addWidget(self.add_reg_btn)
btn_row.addWidget(self.add_btn)
add_grid.addLayout(btn_row, 6, 0, 1, 2)
# Make the second column expand
add_grid.setColumnStretch(0, 0)
add_grid.setColumnStretch(1, 1)
# Console widget
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
# self.btn2 = QPushButton("Button 2")
# self.btn3 = QPushButton("Button 3")
# self.btn4 = QPushButton("Button 4")
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=True)
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
# self.scatter_mi = self.scatter.main_curve
# self.scatter.plot("samx", "samy", "bpm4i")
# seventh_tab_layout.addWidget(self.scatter)
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
# tab_widget.setCurrentIndex(6)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# ninth_tab = QWidget()
# ninth_tab_layout = QVBoxLayout(ninth_tab)
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(8)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
#
# self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
# Vertical splitter between add panel and console
right_splitter = QSplitter(Qt.Vertical, right_panel)
right_splitter.addWidget(add_panel)
right_splitter.addWidget(self.console)
right_splitter.setStretchFactor(0, 0)
right_splitter.setStretchFactor(1, 1)
right_splitter.setSizes([300, 600])
# Put splitter into the right group box
right_layout.addWidget(right_splitter)
# Populate registry on startup
self._populate_registry_widgets()
def _populate_registry_widgets(self):
try:
widget_handler.update_available_widgets()
items = sorted(widget_handler.widget_classes.keys())
except Exception as exc:
print(f"Failed to load registered widgets: {exc}")
items = []
self.registry_combo.clear()
self.registry_combo.addItems(items)
def _on_add_widget_clicked(self):
class_path = self.class_path_edit.text().strip()
shortcut = self.shortcut_edit.text().strip()
kwargs_text = self.kwargs_edit.text().strip()
title = self.title_edit.text().strip() or None
if not class_path or not shortcut:
print("Please provide both class path and shortcut.")
return
kwargs: dict | None = None
if kwargs_text:
try:
parsed = ast.literal_eval(kwargs_text)
if isinstance(parsed, dict):
kwargs = parsed
else:
print("Kwargs must be a Python dict literal, ignoring input.")
except Exception as exc:
print(f"Failed to parse kwargs: {exc}")
try:
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
except Exception as exc:
print(f"Failed to instantiate {class_path}: {exc}")
return
try:
self.add_widget(widget, shortcut, title=title)
except Exception as exc:
print(f"Failed to add widget: {exc}")
return
# focus the newly added tab
idx = self.tab_widget.count() - 1
if idx >= 0:
self.tab_widget.setCurrentIndex(idx)
def _on_add_registered_clicked(self):
widget_type = self.registry_combo.currentText().strip()
shortcut = self.shortcut_edit.text().strip()
kwargs_text = self.kwargs_edit.text().strip()
title = self.title_edit.text().strip() or None
if not widget_type or not shortcut:
print("Please select a registered widget and provide a shortcut.")
return
kwargs: dict | None = None
if kwargs_text:
try:
parsed = ast.literal_eval(kwargs_text)
if isinstance(parsed, dict):
kwargs = parsed
else:
print("Kwargs must be a Python dict literal, ignoring input.")
except Exception as exc:
print(f"Failed to parse kwargs: {exc}")
try:
self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title)
except Exception as exc:
print(f"Failed to add registered widget: {exc}")
return
# focus the newly added tab
idx = self.tab_widget.count() - 1
if idx >= 0:
self.tab_widget.setCurrentIndex(idx)
def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget:
module_path, _, class_name = class_path.rpartition(".")
if not module_path or not class_name:
raise ValueError("class_path must be of the form 'package.module.Class'")
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
if kwargs is None:
obj = cls()
else:
obj = cls(**kwargs)
if not isinstance(obj, QWidget):
raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}")
return obj
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget:
"""Add a QWidget as a new tab and expose it in the Jupyter console.
- widget: a QWidget instance to host in a new tab
- shortcut: variable name used in the console to access it
- title: optional tab title (defaults to shortcut or class name)
"""
if not isinstance(widget, QWidget):
raise TypeError("widget must be a QWidget instance")
if not shortcut or not shortcut.isidentifier():
raise ValueError("shortcut must be a valid Python identifier")
if shortcut in self._widgets_by_name:
raise ValueError(f"A widget with shortcut '{shortcut}' already exists")
if self.console.inprocess is not True:
raise RuntimeError("Adding widgets and exposing them requires inprocess console")
tab_title = title or shortcut or widget.__class__.__name__
self.tab_widget.addTab(widget, tab_title)
self._widgets_by_name[shortcut] = widget
# Expose in console under the given shortcut
self._push_to_console({shortcut: widget})
return widget
def add_widget_by_class_path(
self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
) -> QWidget:
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
return self.add_widget(widget, shortcut, title=title)
def add_widget_by_type(
self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
) -> QWidget:
"""Instantiate a registered BEC widget by its type string and add it as a tab.
If kwargs does not contain `object_name`, it will default to the provided shortcut.
"""
# Ensure registry is loaded
widget_handler.update_available_widgets()
cls = widget_handler.widget_classes.get(widget_type)
if cls is None:
raise ValueError(f"Unknown registered widget type: {widget_type}")
if kwargs is None:
kwargs = {"object_name": shortcut}
else:
kwargs = dict(kwargs)
kwargs.setdefault("object_name", shortcut)
# Instantiate and add
widget = cls(**kwargs)
if not isinstance(widget, QWidget):
raise TypeError(
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
)
return self.add_widget(widget, shortcut, title=title)
def _push_to_console(self, mapping: Dict[str, Any]):
"""Push Python objects into the inprocess kernel user namespace."""
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(mapping)
else:
raise RuntimeError("Can only push variables when using inprocess kernel")
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.cleanup()
self.dock.close()
# 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()
self.console.close()
super().closeEvent(event)
@@ -169,18 +391,26 @@ if __name__ == "__main__": # pragma: no cover
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
apply_theme("dark")
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
client = bec_dispatcher.client
client.start()
win = JupyterConsoleWindow()
# Examples: add two widgets programmatically to demonstrate usage
try:
win.add_widget_by_type("Waveform", shortcut="wf")
except Exception as exc:
print(f"Example add failed (Waveform by type): {exc}")
try:
win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True})
except Exception as exc:
print(f"Example add failed (Image by type): {exc}")
win.show()
win.resize(1500, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -173,7 +173,7 @@ class FakePositioner(BECPositioner):
def set_read_value(self, value):
self.read_value = value
def read(self):
def read(self, cached=False):
return self.signals
def set_limits(self, limits):

View File

@@ -77,6 +77,8 @@ class BECConnector:
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
widget_removed = Signal()
name_established = Signal(str)
def __init__(
self,
@@ -127,6 +129,17 @@ class BECConnector:
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
try: # shutdown ophyd threads if any
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
logger.info("Ophyd dispatcher shut down successfully.")
except Exception as e:
logger.warning(
f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}"
)
logger.info("Shutting down BEC Client", repr(client))
client.shutdown()
@@ -161,8 +174,6 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -186,24 +197,6 @@ class BECConnector:
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
@@ -224,6 +217,10 @@ class BECConnector:
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
try:
self.name_established.emit(self.object_name)
except RuntimeError:
return
def _enforce_unique_sibling_name(self):
"""
@@ -233,7 +230,7 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.processEvents()
QApplication.sendPostedEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
@@ -470,6 +467,7 @@ class BECConnector:
# i.e. Curve Item from Waveform
else:
self.rpc_register.remove_rpc(self)
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""

View File

@@ -38,9 +38,11 @@ def _loaded_submodules_from_specs(
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
exception_text = "".join(traceback.format_exception(e))
if "(most likely due to a circular import)" in exception_text:
logger.warning(f"Circular import encountered while loading {submodule}")
else:
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
yield submodule
@@ -59,7 +61,8 @@ def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
and item is not BECWidget
and not item.__module__.startswith("bec_widgets"),
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)

View File

@@ -0,0 +1,86 @@
import traceback
from pathlib import Path
from typing import Annotated
import copier
import typer
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_repo_path
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
logger = bec_logger.logger
_app = typer.Typer(rich_markup_mode="rich")
def _commit_added_widget(repo: Path, name: str):
git_stage_files(repo, [".copier-answers.yml"])
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
make_commit(repo, f"plugin-manager added new widget: {name}")
logger.info(f"Committing new widget {name}")
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
return name in [w["name"] for w in widget_list]
def _editor_cb(ctx: typer.Context, value: bool):
if value and not ctx.params["use_ui"]:
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
return value
_bold_blue = "\033[34m\033[1m"
_off = "\033[0m"
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
Open the created widget .ui file in bec-designer now?"""
@_app.command()
def widget(
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
open_editor: Annotated[
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
] = True,
):
"""Create a new widget plugin with the given name.
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
bec-designer and the compiled python version will be updated when changes are made and saved."""
if (formatted_name := name.lower().replace("-", "_")) != name:
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
if not formatted_name.isidentifier():
logger.error(
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
)
exit(-1)
logger.info(f"Adding new widget {formatted_name} to the template...")
try:
repo = Path(plugin_repo_path())
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
logger.error(f"Widget {formatted_name} already exists!")
exit(-1)
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
copier.run_update(
repo,
data=plugin_data,
defaults=True,
unsafe=True,
overwrite=True,
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
)
_commit_added_widget(repo, formatted_name)
except Exception:
logger.error(traceback.format_exc())
logger.error("exiting...")
exit(-1)
logger.success(f"Added widget {formatted_name}!")
if open_editor:
open_and_watch_ui_editor(formatted_name)

View File

@@ -0,0 +1,136 @@
import re
import subprocess
from pathlib import Path
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from watchdog.events import (
DirCreatedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEvent,
FileSystemEventHandler,
)
from watchdog.observers import Observer
from bec_widgets.utils.bec_designer import open_designer
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
class RecompileHandler(FileSystemEventHandler):
def __init__(self, in_file: Path, out_file: Path) -> None:
super().__init__()
self.in_file = str(in_file)
self.out_file = str(out_file)
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
self._widget_import_re = re.compile(
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
)
self._widget_modules = {
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
}
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
self.recompile(event)
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
self.recompile(event)
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
self.recompile(event)
def recompile(self, event: FileSystemEvent) -> None:
if event.src_path == self.in_file or event.dest_path == self.in_file:
self._recompile()
def _recompile(self):
logger.success(".ui file modified, recompiling...")
code = subprocess.call(
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
)
logger.success(f"compilation exited with code {code}")
if code != 0:
return
self._add_comment_to_file()
logger.success("updating imports...")
self._update_imports()
logger.success("formatting...")
code = subprocess.call(
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
)
if code != 0:
logger.error(f"Error while running black on {self.out_file}, code: {code}")
return
code = subprocess.call(
[
"isort",
"--line-length=100",
"--profile=black",
"--multi-line=3",
"--trailing-comma",
self.out_file,
]
)
if code != 0:
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
return
logger.success("done!")
def _add_comment_to_file(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
f.write(
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
)
f.write(initial)
def _update_imports(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
qtpy_imports = re.sub(
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
)
print(self._widget_modules)
print(re.findall(self._widget_import_re, qtpy_imports))
widget_imports = re.sub(
self._widget_import_re,
lambda ob: (
f"from {module} import {ob.group(2)}"
if (module := self._widget_modules.get(ob.group(2))) is not None
else ob.group(1)
),
qtpy_imports,
)
f.write(widget_imports)
f.truncate()
def open_and_watch_ui_editor(widget_name: str):
logger.info(f"Opening the editor for {widget_name}, and watching")
repo = Path(plugin_repo_path())
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
ui_file = widget_dir / f"{widget_name}.ui"
ui_outfile = widget_dir / f"{widget_name}_ui.py"
logger.info(
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
)
recompile_handler = RecompileHandler(ui_file, ui_outfile)
observer = Observer()
observer.schedule(recompile_handler, str(ui_file.parent))
observer.start()
try:
open_designer([str(ui_file)])
finally:
observer.stop()
observer.join()
logger.info("Editing session ended, exiting...")

View File

@@ -1,15 +1,20 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
import darkdetect
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, 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.colors import set_theme
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
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
@@ -23,7 +28,7 @@ class BECWidget(BECConnector):
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
USER_ACCESS = ["remove"]
USER_ACCESS = ["remove", "attach", "detach"]
# pylint: disable=too-many-arguments
def __init__(
@@ -32,6 +37,8 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
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,
):
@@ -41,8 +48,7 @@ class BECWidget(BECConnector):
>>> class MyWidget(BECWidget, QWidget):
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
>>> super().__init__(client=client, config=config, gui_id=gui_id)
>>> QWidget.__init__(self, parent=parent)
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
Args:
@@ -52,31 +58,37 @@ 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
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
# Instead, we will set the theme to the system setting on startup
if darkdetect.isDark():
set_theme("dark")
else:
set_theme("light")
if theme_update:
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
# Initialize optional busy loader overlay utility (lazy by default)
self._busy_overlay = None
self._loading = False
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}")
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
if hasattr(qapp, "theme"):
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
@SafeSlot(str)
@SafeSlot()
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -85,9 +97,78 @@ class BECWidget(BECConnector):
theme = qapp.theme.theme
else:
theme = "dark"
self._update_overlay_theme(theme)
self.apply_theme(theme)
@Slot(str)
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
"""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)
self._busy_overlay = overlay
return overlay
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> 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())
self._busy_overlay.raise_()
self._busy_overlay.show()
def set_busy(self, enabled: bool, text: str | None = None) -> None:
"""
Enable/disable the loading overlay. Optionally update the 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.
"""
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 enabled:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
else:
self._busy_overlay.hide()
self._loading = bool(enabled)
def is_busy(self) -> bool:
"""
Check if the loading overlay is enabled.
Returns:
bool: True if the loading overlay is enabled, False otherwise.
"""
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):
"""
Apply the theme to the widget.
@@ -96,12 +177,140 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
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()
except Exception:
logger.warning(f"Failed to apply theme {theme} to {self}")
def get_help_md(self) -> str:
"""
Method to override in subclasses to provide help text in markdown format.
Returns:
str: The help text in markdown format.
"""
return ""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def screenshot_bytes(
self,
*,
max_width: int | None = None,
max_height: int | None = None,
fmt: str = "PNG",
quality: int = -1,
) -> QByteArray:
"""
Grab this widget, optionally scale to a max size, and return encoded image bytes.
If max_width/max_height are omitted (the default), capture at full resolution.
Args:
max_width(int, optional): Maximum width of the screenshot.
max_height(int, optional): Maximum height of the screenshot.
fmt(str, optional): Image format (e.g., "PNG", "JPEG").
quality(int, optional): Image quality (0-100), -1 for default.
Returns:
QByteArray: The screenshot image bytes.
"""
if not isinstance(self, QWidget):
return QByteArray()
if not hasattr(self, "grab"):
raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}")
pixmap: QPixmap = self.grab()
if pixmap.isNull():
return QByteArray()
if max_width is not None or max_height is not None:
w = max_width if max_width is not None else pixmap.width()
h = max_height if max_height is not None else pixmap.height()
pixmap = pixmap.scaled(
w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation
)
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
pixmap.save(buf, fmt, quality)
buf.close()
return ba
def attach(self):
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
return
if not dock.isFloating():
return
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
return
if dock.isFloating():
return
dock.setFloating()
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
# Tear down busy overlay explicitly to stop spinner and remove filters
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and shiboken6.isValid(overlay):
try:
overlay.hide()
filt = getattr(overlay, "_filter", None)
if filt is not None and shiboken6.isValid(filt):
try:
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
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

@@ -0,0 +1,253 @@
from __future__ import annotations
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
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
class _OverlayEventFilter(QObject):
"""Keeps the overlay sized and stacked over its target widget."""
def __init__(self, target: QWidget, overlay: QWidget):
super().__init__(target)
self._target = target
self._overlay = overlay
def eventFilter(self, obj, event):
if obj is self._target and event.type() in (
QEvent.Resize,
QEvent.Show,
QEvent.LayoutRequest,
QEvent.Move,
):
self._overlay.setGeometry(self._target.rect())
self._overlay.raise_()
return False
class BusyLoaderOverlay(QWidget):
"""
A semi-transparent scrim with centered text and an animated spinner.
Call show()/hide() directly, or use via `install_busy_loader(...)`.
Args:
parent(QWidget): The parent widget to overlay.
text(str): Initial text to display.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **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._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)
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)
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()
# Start hidden; interactions beneath are blocked while visible
self.hide()
# --- API ---
def set_text(self, text: str):
"""
Update the overlay text.
Args:
text(str): The text to display on the overlay.
"""
self._label.setText(text)
def set_opacity(self, opacity: float):
"""
Set overlay opacity (0..1).
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()
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")
else:
# Fallback neutrals
self._bg = QColor(30, 30, 30)
self._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};")
self._frame.setStyleSheet(
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
)
self.update()
# --- QWidget overrides ---
def showEvent(self, e):
self._spinner.start()
super().showEvent(e)
def hideEvent(self, e):
self._spinner.stop()
super().hideEvent(e)
def resizeEvent(self, e):
super().resizeEvent(e)
r = self.rect().adjusted(10, 10, -10, -10)
self._frame.setGeometry(r)
def paintEvent(self, e):
super().paintEvent(e)
def install_busy_loader(
target: QWidget, text: str = "Loading…", 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.setGeometry(target.rect())
filt = _OverlayEventFilter(target, overlay)
overlay._filter = filt # type: ignore[attr-defined]
target.installEventFilter(filt)
if start_loading:
overlay.show()
return overlay
# --------------------------
# 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
app = QApplication(sys.argv)
apply_theme("light")
w = DemoWindow()
w.show()
sys.exit(app.exec())

View File

@@ -1,19 +1,17 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Literal
from typing import Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from bec_qthemes import apply_theme as apply_theme_global
from bec_qthemes._theme import AccentColors
from pydantic_core import PydanticCustomError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
@@ -23,118 +21,35 @@ def get_theme_name():
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
# FIXME this is legacy code, should be removed in the future
app = QApplication.instance()
palette = app.palette()
return palette
def get_accent_colors() -> AccentColors | None:
def get_accent_colors() -> AccentColors:
"""
Get the accent colors for the current theme. These colors are extensions of the color palette
and are used to highlight specific elements in the UI.
"""
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return None
accent_colors = AccentColors()
return accent_colors
return QApplication.instance().theme.accent_colors
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
if theme != "auto":
return
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def process_all_deferred_deletes(qapp):
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
qapp.processEvents(QEventLoop.AllEvents)
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
"""
app = QApplication.instance()
graphic_layouts = [
child
for top in app.topLevelWidgets()
for child in top.findChildren(pg.GraphicsLayoutWidget)
]
plot_items = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.PlotItem)
]
histograms = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.HistogramLUTItem)
]
# Update background color based on the theme
if theme == "light":
background_color = "#e9ecef" # Subtle contrast for light mode
foreground_color = "#141414"
label_color = "#000000"
axis_color = "#666666"
else:
background_color = "#141414" # Dark mode
foreground_color = "#e9ecef"
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# update GraphicsLayoutWidget
pg.setConfigOptions(foreground=foreground_color, background=background_color)
for pg_widget in graphic_layouts:
pg_widget.setBackground(background_color)
# update PlotItems
for plot_item in plot_items:
for axis in ["left", "right", "top", "bottom"]:
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
# Change title color
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
# Change legend color
if hasattr(plot_item, "legend") and plot_item.legend is not None:
plot_item.legend.setLabelTextColor(label_color)
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
for sample, label in plot_item.legend.items:
label_text = label.text
label.setText(label_text, color=label_color)
# update HistogramLUTItem
for histogram in histograms:
histogram.axis.setPen(pg.mkPen(color=axis_color))
histogram.axis.setTextPen(pg.mkPen(color=label_color))
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
process_all_deferred_deletes(QApplication.instance())
apply_theme_global(theme)
process_all_deferred_deletes(QApplication.instance())
class Colors:

View File

@@ -11,6 +11,7 @@ from qtpy.QtWidgets import (
QPushButton,
QSizePolicy,
QSpacerItem,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -122,15 +123,14 @@ class CompactPopupWidget(QWidget):
self.compact_view_widget = QWidget(self)
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
QHBoxLayout(self.compact_view_widget)
self.compact_view_widget.layout().setSpacing(0)
self.compact_view_widget.layout().setSpacing(5)
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
self.compact_view_widget.layout().addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
)
self.compact_label = QLabel(self.compact_view_widget)
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QPushButton(self.compact_view_widget)
self.compact_show_popup.setFlat(True)
self.compact_show_popup = QToolButton(self.compact_view_widget)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)
@@ -144,6 +144,7 @@ class CompactPopupWidget(QWidget):
self.container.setVisible(True)
layout(self.container)
self.layout = self.container.layout()
self._compact_view = False
self.compact_show_popup.clicked.connect(self.show_popup)
@@ -210,7 +211,7 @@ class CompactPopupWidget(QWidget):
@Property(bool)
def compact_view(self):
return self.compact_label.isVisible()
return self._compact_view
@compact_view.setter
def compact_view(self, set_compact: bool):
@@ -220,6 +221,7 @@ class CompactPopupWidget(QWidget):
the full view is displayed. This is handled by toggling visibility of
the container widget or the compact view widget.
"""
self._compact_view = set_compact
if set_compact:
self.compact_view_widget.setVisible(True)
self.container.setVisible(False)
@@ -259,12 +261,3 @@ class CompactPopupWidget(QWidget):
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)

View File

@@ -209,8 +209,11 @@ class Crosshair(QObject):
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
else:
# Handle all curves
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
# Handle visible items in the plot item
self.items = self.plot_item.visible_items()
else: # Non PlotBase case
# Handle all items
self.items = self.plot_item.items
# Create or update markers

View File

@@ -2,7 +2,9 @@ import functools
import sys
import traceback
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
@@ -90,6 +92,52 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
return decorator
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
"""Internal function used by SafeConnect to handle weak references to slots."""
instance = weak_instance()
slot_func = weak_slot()
# Check if the python object has already been garbage collected
if instance is None or slot_func is None:
return
# Check if the python object has already been marked for deletion
if getattr(instance, "_destroyed", False):
return
# Check if the C++ object is still valid
if not shiboken6.isValid(instance):
return
if connect_args:
slot_func(*connect_args)
slot_func()
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
"""
Method to safely handle Qt signal-slot connections. The python object is only forwarded
as a weak reference to avoid stale objects.
Args:
instance: The instance to connect.
signal: The signal to connect to.
slot: The slot to connect.
Example:
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
"""
weak_instance = safe_ref(instance)
weak_slot = safe_ref(slot)
# Create a partial function that will check weak references before calling the actual slot
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
# Connect the signal to the safe connect slot wrapper
return signal.connect(safe_slot)
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
to the passed function, to display errors instead of potentially raising an exception

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
@@ -19,7 +19,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all"
@@ -31,10 +32,11 @@ class ExpandableGroupFrame(QFrame):
super().__init__(parent=parent)
self._expanded = expanded
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self._title_text = f"<b>{title}</b>"
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setContentsMargins(5, 0, 0, 0)
self.setLayout(self._layout)
self._create_title_layout(title, icon)
@@ -49,21 +51,27 @@ class ExpandableGroupFrame(QFrame):
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._internal_title_layout = QHBoxLayout()
self._title_layout.addLayout(self._internal_title_layout)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title = ClickableLabel()
self._set_title_text(self._title_text)
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self._internal_title_layout.addWidget(self._title_icon)
self._internal_title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._internal_title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def get_title_layout(self) -> QHBoxLayout:
return self._internal_title_layout
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
@@ -112,6 +120,18 @@ class ExpandableGroupFrame(QFrame):
else:
self._title_icon.setVisible(False)
@SafeProperty(str)
def title_text(self): # type: ignore
return self._title_text
@title_text.setter
def title_text(self, title_text: str):
self._title_text = title_text
self._set_title_text(self._title_text)
def _set_title_text(self, title_text: str):
self._title.setText(title_text)
# Application example
if __name__ == "__main__": # pragma: no cover

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from types import NoneType
from types import GenericAlias, NoneType, UnionType
from typing import NamedTuple
from bec_lib.logger import bec_logger
@@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBox
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
@@ -216,6 +216,9 @@ class PydanticModelForm(TypedForm):
self._connect_to_theme_change()
@SafeSlot()
def clear(self): ...
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
@@ -280,3 +283,24 @@ class PydanticModelForm(TypedForm):
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
return False
class PydanticModelFormItem(DynamicFormItem):
def __init__(
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
) -> None:
self._data_model = model
super().__init__(parent=parent, spec=spec)
self._main_widget.form_data_updated.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = PydanticModelForm(data_model=self._data_model)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.get_form_data()
def setValue(self, value: dict):
self._main_widget.set_data(self._data_model.model_validate(value))

View File

@@ -1,10 +1,23 @@
from __future__ import annotations
import inspect
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
from typing import (
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
OrderedDict,
Protocol,
TypeVar,
get_args,
runtime_checkable,
)
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -158,9 +171,10 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
# Sadly, QWidget and ABC are not compatible
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
@@ -175,6 +189,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
constraints from the field info"""
@@ -350,11 +365,13 @@ class DictFormItem(DynamicFormItem):
self._main_widget.replace_data(value)
class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
_IW = TypeVar("_IW", bound=int | float | str)
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
item: type[_IW]
widget: type[QWidget]
default: int | float | str
default: _IW
class ListFormItem(DynamicFormItem):
@@ -380,7 +397,7 @@ class ListFormItem(DynamicFormItem):
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
return QSize(default.width(), QFontMetrics(self.font()).height() * 4)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
@@ -430,10 +447,17 @@ class ListFormItem(DynamicFormItem):
self._add_list_item(val)
self._repop(self._data)
def _item_height(self):
return int(QFontMetrics(self.font()).height() * 1.5)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
item_widget.setMinimumHeight(self._item_height())
self._main_widget.setGridSize(QSize(0, self._item_height()))
if (layout := item_widget.layout()) is not None:
layout.setContentsMargins(0, 0, 0, 0)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
@@ -470,14 +494,11 @@ class ListFormItem(DynamicFormItem):
self._data = list(value)
self._repop(self._data)
def _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
@@ -545,7 +566,14 @@ class StrLiteralFormItem(DynamicFormItem):
self._main_widget.setCurrentIndex(-1)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
@runtime_checkable
class _ItemTypeFn(Protocol):
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
WidgetTypeRegistry = OrderedDict[
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
]
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
@@ -586,7 +614,10 @@ def widget_from_type(
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
return widget_type
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
return widget_type
return widget_type(spec)
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)

View File

@@ -7,7 +7,7 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)

View File

@@ -0,0 +1,735 @@
"""Module providing a guided help system for creating interactive GUI tours."""
from __future__ import annotations
import sys
import weakref
from typing import Callable, Dict, List, TypedDict
from uuid import uuid4
import louie
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.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenuBar,
QPushButton,
QToolBar,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class TourStep(TypedDict):
"""Type definition for a tour step."""
widget_ref: (
louie.saferef.BoundMethodWeakref
| weakref.ReferenceType[
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
]
| Callable[[], tuple[QWidget | QAction, str | None]]
| None
)
text: str
title: str
class TutorialOverlay(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Keep mouse events enabled for the overlay but we'll handle them manually
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
self.current_rect = QRect()
self.message_box = self._create_message_box()
self.message_box.hide()
def _create_message_box(self):
box = QFrame(self)
app = QApplication.instance()
bg_color = app.palette().window().color()
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)
top_layout = QHBoxLayout()
# Close button on the left with material icon
self.close_btn = QPushButton()
self.close_btn.setIcon(material_icon("close"))
self.close_btn.setToolTip("Close")
self.close_btn.setMaximumSize(32, 32)
# Step indicator on the right
self.step_label = QLabel()
self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;")
top_layout.addWidget(self.close_btn)
top_layout.addStretch()
top_layout.addWidget(self.step_label)
# Main content label
self.label = QLabel()
self.label.setWordWrap(True)
# Bottom navigation buttons
btn_layout = QHBoxLayout()
# Back button with material icon
self.back_btn = QPushButton("Back")
self.back_btn.setIcon(material_icon("arrow_back"))
# Next button with material icon
self.next_btn = QPushButton("Next")
self.next_btn.setIcon(material_icon("arrow_forward"))
btn_layout.addStretch()
btn_layout.addWidget(self.back_btn)
btn_layout.addWidget(self.next_btn)
layout.addLayout(top_layout)
layout.addWidget(self.label)
layout.addLayout(btn_layout)
return box
def paintEvent(self, event): # pylint: disable=unused-argument
if not self.current_rect.isValid():
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Create semi-transparent overlay color
overlay_color = QColor(0, 0, 0, 160)
# Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive.
r = self.current_rect
rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height()
# Paint overlay in 4 regions around the highlighted widget using exclusive bounds
# Top region (everything above the highlight)
if rect_y > 0:
top_rect = QRect(0, 0, self.width(), rect_y)
painter.fillRect(top_rect, overlay_color)
# Bottom region (everything below the highlight)
bottom_y = rect_y + rect_h
if bottom_y < self.height():
bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y)
painter.fillRect(bottom_rect, overlay_color)
# Left region (to the left of the highlight)
if rect_x > 0:
left_rect = QRect(0, rect_y, rect_x, rect_h)
painter.fillRect(left_rect, overlay_color)
# Right region (to the right of the highlight)
right_x = rect_x + rect_w
if right_x < self.width():
right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h)
painter.fillRect(right_rect, overlay_color)
# Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap.
border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4)
painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(border_rect, 8, 8)
painter.end()
def show_step(
self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1
):
"""
rect must already be in the overlay's coordinate space (i.e. mapped).
This method positions the message box so it does not overlap the rect.
Args:
rect(QRect): rectangle to highlight
title(str): Title text for the step
text(str): Main content text for the step
current_step(int): Current step number
total_steps(int): Total number of steps in the tour
"""
self.current_rect = rect
# Update step indicator in top right
self.step_label.setText(f"Step {current_step} of {total_steps}")
# Update main content text (without step number since it's now in top right)
content_text = f"<b>{title}</b><br>{text}" if title else text
self.label.setText(content_text)
self.message_box.adjustSize() # ensure layout applied
message_size = self.message_box.size() # actual widget size (width, height)
spacing = 15
# Preferred placement: to the right, vertically centered
pos_x = rect.right() + spacing
pos_y = rect.center().y() - (message_size.height() // 2)
# If it would go off the right edge, try left of the widget
if pos_x + message_size.width() > self.width():
pos_x = rect.left() - message_size.width() - spacing
# vertical center is still good, but if that overlaps top/bottom we'll clamp below
# If it goes off the left edge (no space either side), place below, centered horizontally
if pos_x < spacing:
pos_x = rect.center().x() - (message_size.width() // 2)
pos_y = rect.bottom() + spacing
# If it goes off the bottom, try moving it above the widget
if pos_y + message_size.height() > self.height() - spacing:
# if there's room above the rect, put it there
candidate_y = rect.top() - message_size.height() - spacing
if candidate_y >= spacing:
pos_y = candidate_y
else:
# otherwise clamp to bottom with spacing
pos_y = max(spacing, self.height() - message_size.height() - spacing)
# If it goes off the top, clamp down
pos_y = max(spacing, pos_y)
# Make sure we don't poke the left edge
pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing))
# Apply geometry and show
self.message_box.setGeometry(
int(pos_x), int(pos_y), message_size.width(), message_size.height()
)
self.message_box.show()
self.update()
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Resize:
self.setGeometry(obj.rect())
return False
class GuidedTour(QObject):
"""
A guided help system for creating interactive GUI tours.
Allows developers to register widgets with help text and create guided tours.
"""
tour_started = Signal()
tour_finished = Signal()
step_changed = Signal(int, int) # current_step, total_steps
def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True):
super().__init__()
self._visible_check: bool = enforce_visibility
self.main_window_ref = saferef.safe_ref(main_window)
self.overlay = None
self._registered_widgets: Dict[str, TourStep] = {}
self._tour_steps: List[TourStep] = []
self._current_index = 0
self._active = False
@property
def main_window(self) -> QWidget | None:
"""Get the main window from weak reference."""
if self.main_window_ref and callable(self.main_window_ref):
widget = self.main_window_ref()
if isinstance(widget, QWidget):
return widget
return None
def register_widget(
self,
*,
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
text: str = "",
title: str = "",
) -> str:
"""
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.
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).
Returns:
str: The unique ID for the registered widget.
"""
step_id = str(uuid4())
# If it's a plain callable
if callable(widget) and not hasattr(widget, "__self__"):
widget_ref = widget
default_title = "Widget"
elif isinstance(widget, QAction):
widget_ref = weakref.ref(widget)
default_title = widget.text() or "Action"
elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button):
def _resolve_toolbar_button(toolbar_action=widget):
button = toolbar_action.get_toolbar_button()
return (button, None)
widget_ref = _resolve_toolbar_button
default_title = getattr(widget, "tooltip", "Toolbar Menu")
else:
widget_ref = saferef.safe_ref(widget)
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
self._registered_widgets[step_id] = {
"widget_ref": widget_ref,
"text": text,
"title": title or default_title,
}
logger.debug(f"Registered widget {title or default_title} with ID {step_id}")
return step_id
def _action_highlight_rect(self, action: QAction) -> QRect | None:
"""
Try to find the QRect in main_window coordinates that should be highlighted for the given QAction.
Returns None if not found (e.g. not visible).
"""
mw = self.main_window
if mw is None:
return None
# Try toolbars first
for tb in mw.findChildren(QToolBar):
btn = tb.widgetForAction(action)
if btn and btn.isVisible():
rect = btn.rect()
top_left = btn.mapTo(mw, rect.topLeft())
return QRect(top_left, rect.size())
# Try menu bars
menubars = []
if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)):
mb = mw.menuBar()
if mb and mb not in menubars:
menubars.append(mb)
menubars += [mb for mb in mw.findChildren(QMenuBar) 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:
"""
Unregister a previously registered widget.
Args:
step_id (str): The unique ID of the registered widget.
Returns:
bool: True if the widget was unregistered, False if not found.
"""
if self._active:
raise RuntimeError("Cannot unregister widget while tour is active")
if step_id in self._registered_widgets:
if self._registered_widgets[step_id] in self._tour_steps:
self._tour_steps.remove(self._registered_widgets[step_id])
del self._registered_widgets[step_id]
return True
return False
def create_tour(self, step_ids: List[str] | None = None) -> bool:
"""
Create a tour from registered widget IDs.
Args:
step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included.
Returns:
bool: True if the tour was created successfully, False if any step IDs were not found
"""
if step_ids is None:
step_ids = list(self._registered_widgets.keys())
tour_steps = []
for step_id in step_ids:
if step_id not in self._registered_widgets:
logger.error(f"Step ID {step_id} not found")
return False
tour_steps.append(self._registered_widgets[step_id])
self._tour_steps = tour_steps
logger.info(f"Created tour with {len(tour_steps)} steps")
return True
@SafeSlot()
def start_tour(self):
"""Start the guided tour."""
if not self._tour_steps:
self.create_tour()
if self._active:
logger.warning("Tour already active")
return
main_window = self.main_window
if main_window is None:
logger.error("Main window no longer exists (weak reference is dead)")
return
self._active = True
self._current_index = 0
# Create overlay
self.overlay = TutorialOverlay(main_window)
self.overlay.setGeometry(main_window.rect())
self.overlay.show()
main_window.installEventFilter(self.overlay)
# Connect signals
self.overlay.next_btn.clicked.connect(self.next_step)
self.overlay.back_btn.clicked.connect(self.prev_step)
self.overlay.close_btn.clicked.connect(self.stop_tour)
main_window.installEventFilter(self)
self._show_current_step()
self.tour_started.emit()
logger.info("Started guided tour")
@SafeSlot()
def stop_tour(self):
"""Stop the current tour."""
if not self._active:
return
self._active = False
main_window = self.main_window
if self.overlay and main_window:
main_window.removeEventFilter(self.overlay)
self.overlay.hide()
self.overlay.deleteLater()
self.overlay = None
if main_window:
main_window.removeEventFilter(self)
self.tour_finished.emit()
logger.info("Stopped guided tour")
@SafeSlot()
def next_step(self):
"""Move to next step or finish tour if on last step."""
if not self._active:
return
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
# On last step, finish the tour
self.stop_tour()
@SafeSlot()
def prev_step(self):
"""Move to previous step."""
if not self._active:
return
if self._current_index > 0:
self._current_index -= 1
self._show_current_step()
def _show_current_step(self):
"""Display the current step."""
if not self._active or not self.overlay:
return
step = self._tour_steps[self._current_index]
step_title = step["title"]
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.")
return
main_window = self.main_window
if main_window is None:
logger.error("Main window no longer exists (weak reference is dead)")
self.stop_tour()
return
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
if highlight_rect is None:
return
# Calculate step numbers
current_step = self._current_index + 1
total_steps = len(self._tour_steps)
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:
self.overlay.next_btn.setText("Finish")
self.overlay.next_btn.setIcon(material_icon("check"))
self.overlay.next_btn.setEnabled(True)
else:
self.overlay.next_btn.setText("Next")
self.overlay.next_btn.setIcon(material_icon("arrow_forward"))
self.overlay.next_btn.setEnabled(True)
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
"""
Resolve the target widget/action for the given step.
Args:
step(TourStep): The tour step to resolve.
Returns:
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
"""
widget_ref = step.get("widget_ref")
step_text = step.get("text", "")
if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)):
target = widget_ref()
else:
target = widget_ref
if target is None:
return None, step_text
if callable(target) and not isinstance(target, (QWidget, QAction)):
result = target()
if isinstance(result, tuple):
target, alt_text = result
if alt_text:
step_text = alt_text
else:
target = result
return target, step_text
def _get_highlight_rect(
self, main_window: QWidget, target: QWidget | QAction, step_title: str
) -> QRect | None:
"""
Get the QRect in main_window coordinates to highlight for the given target.
Args:
main_window(QWidget): The main window containing the target.
target(QWidget | QAction): The target widget or action to highlight.
step_title(str): The title of the current step (for logging purposes).
Returns:
QRect | None: The rectangle to highlight, or None if not found/visible.
"""
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}.",
)
return None
return rect
if isinstance(target, QWidget):
if self._visible_check:
if not target.isVisible():
self._advance_past_invalid_step(
step_title, reason=f"Widget {target!r} is not visible."
)
return None
rect = target.rect()
top_left = target.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)}"
)
return None
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
"""
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()
def get_registered_widgets(self) -> Dict[str, TourStep]:
"""Get all registered widgets."""
return self._registered_widgets.copy()
def clear_registrations(self):
"""Clear all registered widgets."""
if self._active:
self.stop_tour()
self._registered_widgets.clear()
self._tour_steps.clear()
logger.info("Cleared all registrations")
def set_visibility_enforcement(self, enabled: bool):
"""Enable or disable visibility checks when highlighting widgets."""
self._visible_check = enabled
def eventFilter(self, obj, event):
"""Handle window resize/move events to update step positioning."""
if event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
if self._active:
self._show_current_step()
return super().eventFilter(obj, event)
################################################################################
############ # Example usage of GuidedTour system ##############################
################################################################################
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Guided Tour Demo")
central = QWidget()
layout = QVBoxLayout(central)
layout.setSpacing(12)
layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support."))
self.btn1 = QPushButton("Primary Button")
self.btn2 = QPushButton("Secondary Button")
self.status_label = QLabel("Use the controls below or the toolbar to interact.")
self.start_tour_btn = QPushButton("Start Guided Tour")
layout.addWidget(self.btn1)
layout.addWidget(self.btn2)
layout.addWidget(self.status_label)
layout.addStretch()
layout.addWidget(self.start_tour_btn)
self.setCentralWidget(central)
# Guided tour system
self.guided_help = GuidedTour(self)
# Menus for demonstrating QAction support in menu bars
self._init_menu_bar()
# Modular toolbar showcasing QAction targets
self._init_toolbar()
# Register widgets and actions with help text
primary_step = self.guided_help.register_widget(
widget=self.btn1,
text="The primary button updates the status text when clicked.",
title="Primary Button",
)
secondary_step = self.guided_help.register_widget(
widget=self.btn2,
text="The secondary button complements the demo layout.",
title="Secondary Button",
)
toolbar_action_step = self.guided_help.register_widget(
widget=self.toolbar_tour_action.action,
text="Toolbar actions are supported in the guided tour. This one also starts the tour.",
title="Toolbar Tour Action",
)
tools_menu_step = self.guided_help.register_widget(
widget=self.toolbar.components.get_action("menu_tools"),
text="Expandable toolbar menus group related actions. This button opens the tools menu.",
title="Tools Menu",
)
# Create tour from registered widgets
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
widget_ids = self.tour_step_ids
self.guided_help.create_tour(widget_ids)
# Connect start button
self.start_tour_btn.clicked.connect(self.guided_help.start_tour)
def _init_menu_bar(self):
menu_bar = self.menuBar()
info_menu = menu_bar.addMenu("Info")
info_menu.setObjectName("info-menu")
self.info_menu = info_menu
self.info_menu_action = info_menu.menuAction()
self.about_action = info_menu.addAction("About This Demo")
def _init_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(self.toolbar)
self.toolbar_tour_action = MaterialIconAction(
"play_circle", tooltip="Start the guided tour", parent=self
)
self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action)
self.toolbar_highlight_action = MaterialIconAction(
"visibility", tooltip="Highlight the primary button", parent=self
)
self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action)
demo_bundle = self.toolbar.new_bundle("demo")
demo_bundle.add_action("tour-start")
demo_bundle.add_action("inspect-primary")
self._setup_tools_menu()
self.toolbar.show_bundles(["demo", "menu_tools"])
self.toolbar.refresh()
self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour)
def _setup_tools_menu(self):
self.tools_menu_actions: dict[str, MaterialIconAction] = {
"notes": MaterialIconAction(
icon_name="note_add", tooltip="Add a note", filled=True, parent=self
),
"bookmark": MaterialIconAction(
icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self
),
"settings": MaterialIconAction(
icon_name="tune", tooltip="Adjust settings", filled=True, parent=self
),
}
self.tools_menu_action = ExpandableMenuAction(
label="Tools ", actions=self.tools_menu_actions
)
self.toolbar.components.add_safe("menu_tools", self.tools_menu_action)
bundle = ToolbarBundle("menu_tools", self.toolbar.components)
bundle.add_action("menu_tools")
self.toolbar.add_bundle(bundle)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
from bec_qthemes import apply_theme
apply_theme("dark")
w = MainWindow()
w.resize(400, 300)
w.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,247 @@
"""Module providing a simple help inspector tool for QtWidgets."""
from functools import partial
from typing import Callable
from uuid import uuid4
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
class HelpInspector(BECWidget, QtWidgets.QWidget):
"""
A help inspector widget that allows to inspect other widgets in the application.
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
The inspector also allows to register custom callback that are called with the inspected widget
as argument. This may be useful in the future to hook up more callbacks with custom signals.
Args:
parent (QWidget | None): The parent widget of the help inspector.
client: Optional client for BECWidget functionality.
size (tuple[int, int]): Optional size of the icon for the help inspector.
"""
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self._app = QtWidgets.QApplication.instance()
layout = QtWidgets.QHBoxLayout(self) # type: ignore
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._active = False
self._init_ui()
self._callbacks = {}
# Register the default callbacks
self._register_default_callbacks()
# Connect the button toggle signal
self._button.toggled.connect(self._toggle_mode)
def _init_ui(self):
"""Init the UI components."""
colors: AccentColors = get_accent_colors()
self._button = QtWidgets.QToolButton(self.parent())
self._button.setCheckable(True)
self._icon_checked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
)
self._icon_unchecked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
)
self._button.setText("Help Inspect Tool")
self._button.setIcon(self._icon_unchecked())
self._button.setToolTip("Click to enter Help Mode")
self.layout().addWidget(self._button)
def apply_theme(self, theme: str) -> None:
colors = get_accent_colors()
self._icon_checked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
)
self._icon_unchecked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
)
if self._active:
self._button.setIcon(self._icon_checked())
else:
self._button.setIcon(self._icon_unchecked())
@SafeSlot(bool)
def _toggle_mode(self, enabled: bool):
"""
Toggle the help inspection mode.
Args:
enabled (bool): Whether to enable or disable the help inspection mode.
"""
if self._app is None:
self._app = QtWidgets.QApplication.instance()
self._active = enabled
if enabled:
self._app.installEventFilter(self)
self._button.setIcon(self._icon_checked())
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
else:
self._app.removeEventFilter(self)
self._button.setIcon(self._icon_unchecked())
self._button.setChecked(False)
QtWidgets.QApplication.restoreOverrideCursor()
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
"""
Filter events to capture Key_Escape event, and mouse clicks
if event filter is active. Any click event on a widget is suppressed, if
the Inspector is active, and the registered callbacks are called with
the clicked widget as argument.
Args:
obj (QObject): The object that received the event.
event (QEvent): The event to filter.
"""
# If not active, return immediately
if not self._active:
return super().eventFilter(obj, event)
# If active, handle escape key
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
self._toggle_mode(False)
return super().eventFilter(obj, event)
# If active, and left mouse button pressed, handle click
if event.type() == QtCore.QEvent.MouseButtonPress:
if event.button() == QtCore.Qt.LeftButton:
widget = self._app.widgetAt(event.globalPos())
if widget is None:
return super().eventFilter(obj, event)
# Get BECWidget ancestor
# 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)
if widget:
if widget is self:
self._toggle_mode(False)
return True
for cb in self._callbacks.values():
try:
cb(widget)
except Exception as e:
logger.error(f"Error occurred in callback {cb}: {e}")
return True
return super().eventFilter(obj, event)
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
"""
Register a callback to be called when a widget is inspected.
The callback should be callable with the following signature:
callback(widget: QWidget) -> None
Args:
callback (Callable[[QWidget], None]): The callback function to register.
Returns:
str: A unique ID for the registered callback.
"""
cb_id = str(uuid4())
self._callbacks[cb_id] = callback
return cb_id
def unregister_callback(self, cb_id: str):
"""Unregister a previously registered callback."""
self._callbacks.pop(cb_id, None)
def _register_default_callbacks(self):
"""Default behavior: publish tooltip, docstring, bec_help"""
def cb_doc(widget: QtWidgets.QWidget):
docstring = widget.__doc__ or "No documentation available."
self.widget_docstring.emit(docstring)
def cb_help(widget: QtWidgets.QWidget):
tooltip = widget.toolTip() or "No tooltip available."
self.widget_tooltip.emit(tooltip)
def cb_bec_help(widget: QtWidgets.QWidget):
help_text = None
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
try:
help_text = widget.get_help_md()
except Exception as e:
logger.debug(f"Error retrieving help text from {widget}: {e}")
if help_text is None:
help_text = widget.toolTip() or "No help available."
if not isinstance(help_text, str):
logger.error(
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
)
help_text = str(help_text)
self.bec_widget_help.emit(help_text)
self.register_callback(cb_doc)
self.register_callback(cb_help)
self.register_callback(cb_bec_help)
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QtWidgets.QApplication(sys.argv)
main_window = QtWidgets.QMainWindow()
apply_theme("dark")
main_window.setWindowTitle("Help Inspector Test")
central_widget = QtWidgets.QWidget()
main_layout = QtWidgets.QVBoxLayout(central_widget)
dark_mode_button = DarkModeButton(parent=main_window)
main_layout.addWidget(dark_mode_button)
help_inspector = HelpInspector()
main_layout.addWidget(help_inspector)
test_button = QtWidgets.QPushButton("Test Button")
test_button.setToolTip("This is a test button.")
test_line_edit = QtWidgets.QLineEdit()
test_line_edit.setToolTip("This is a test line edit.")
test_label = QtWidgets.QLabel("Test Label")
test_label.setToolTip("")
box = PositionerBox()
layout_1 = QtWidgets.QHBoxLayout()
layout_1.addWidget(test_button)
layout_1.addWidget(test_line_edit)
layout_1.addWidget(test_label)
layout_1.addWidget(box)
main_layout.addLayout(layout_1)
doc_label = QtWidgets.QLabel("Docstring will appear here.")
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
main_layout.addWidget(doc_label)
main_layout.addWidget(tool_tip_label)
main_layout.addWidget(bec_help_label)
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
help_inspector.widget_docstring.connect(doc_label.setText)
help_inspector.bec_widget_help.connect(bec_help_label.setText)
main_window.setCentralWidget(central_widget)
main_window.resize(400, 200)
main_window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,133 @@
import re
from functools import partial
from re import Pattern
from typing import Generic, Iterable, NamedTuple, TypeVar
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.control.device_manager.components._util import (
SORT_KEY_ROLE,
SortableQListWidgetItem,
)
logger = bec_logger.logger
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
def __init__(
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
) -> None:
super().__init__(parent)
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
self.item_tuple = _Items
self._item_class = item_class
self._item_dict: dict[str, _Items] = {}
def __contains__(self, id: str):
return id in self._item_dict
def clear(self) -> None:
self._item_dict = {}
return super().clear()
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
Args:
id (str): the key under which to store the list item in the internal dict
Returns:
The widget created in the addition process
"""
def _remove_item(item: QListWidgetItem):
self.takeItem(self.row(item))
del self._item_dict[id]
self.sortItems()
def _updatesize(item: QListWidgetItem, item_widget: _EF):
item_widget.adjustSize()
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
item = SortableQListWidgetItem(self)
item.setData(SORT_KEY_ROLE, id) # used for sorting
item_widget = self._item_class(*args, **kwargs)
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
item_widget.imminent_deletion.connect(partial(_remove_item, item))
item_widget.broadcast_size_hint.connect(item.setSizeHint)
self.addItem(item)
self.setItemWidget(item, item_widget)
self._item_dict[id] = self.item_tuple(item, item_widget)
item.setSizeHint(item_widget.sizeHint())
return (item, item_widget)
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
items = [self.takeItem(0) for i in range(self.count())]
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
for it in items:
self.addItem(it)
# reattach its custom widget
widget = self.itemWidget(it)
if widget:
self.setItemWidget(it, widget)
def item_widget_pairs(self):
return self._item_dict.values()
def widgets(self):
return (i.widget for i in self._item_dict.values())
def get_item_widget(self, id: str):
if (item := self._item_dict.get(id)) is None:
return None
return item
def set_hidden_pattern(self, pattern: Pattern):
self.hide_all()
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
def set_hidden(self, ids: Iterable[str]):
self._set_hidden(ids, True)
def _set_hidden(self, ids: Iterable[str], hidden: bool):
for id in ids:
if (_item := self._item_dict.get(id)) is not None:
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
else:
logger.warning(
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
)
self.sortItems()
def hide_all(self):
self.set_hidden_state_on_all(True)
def unhide_all(self):
self.set_hidden_state_on_all(False)
def set_hidden_state_on_all(self, hidden: bool):
for _item in self._item_dict.values():
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
self.sortItems()
@SafeSlot(str)
def update_filter(self, value: str):
if value == "":
return self.unhide_all()
try:
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
except Exception:
self.unhide_all()

View File

@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
{widget_import}
@@ -20,6 +21,8 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = {plugin_name_pascal}(parent)
return t

View File

@@ -201,7 +201,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):
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)

View File

@@ -0,0 +1,694 @@
from __future__ import annotations
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFontDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class PropertyEditor(QWidget):
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
super().__init__(parent)
self._target = target
self._bec_only = show_only_bec
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Name row
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(target.objectName())
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# BEC only checkbox
filter_row = QHBoxLayout()
self.chk_show_qt = QCheckBox("Show Qt properties")
self.chk_show_qt.setChecked(False)
filter_row.addWidget(self.chk_show_qt)
filter_row.addStretch(1)
layout.addLayout(filter_row)
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
# Main tree widget
self.tree = QTreeWidget(self)
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Property", "Value"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(False)
layout.addWidget(self.tree)
self._build()
def _class_chain(self):
chain = []
mo = self._target.metaObject()
while mo is not None:
chain.append(mo)
mo = mo.superClass()
return chain
def set_show_only_bec(self, flag: bool):
self._bec_only = flag
self._build()
def _set_equal_columns(self):
header = self.tree.header()
header.setSectionResizeMode(0, QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Interactive)
w = self.tree.viewport().width() or self.tree.width()
if w > 0:
half = max(1, w // 2)
self.tree.setColumnWidth(0, half)
self.tree.setColumnWidth(1, w - half)
def _build(self):
self.tree.clear()
for mo in self._class_chain():
class_name = mo.className()
if self._bec_only and not self._is_bec_metaobject(mo):
continue
group_item = QTreeWidgetItem(self.tree, [class_name])
group_item.setFirstColumnSpanned(True)
start = mo.propertyOffset()
end = mo.propertyCount()
for i in range(start, end):
prop = mo.property(i)
if (
not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
name = prop.name()
if name == "objectName":
continue
value = self._target.property(name)
self._add_property_row(group_item, name, value, prop)
if group_item.childCount() == 0:
idx = self.tree.indexOfTopLevelItem(group_item)
self.tree.takeTopLevelItem(idx)
self.tree.expandAll()
QTimer.singleShot(0, self._set_equal_columns)
def _enum_int(self, obj) -> int:
return int(getattr(obj, "value", obj))
def _make_sizepolicy_editor(self, name: str, sp):
if not isinstance(sp, QSizePolicy):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
h_combo = QComboBox(wrap)
v_combo = QComboBox(wrap)
hs = QSpinBox(wrap)
vs = QSpinBox(wrap)
for b in (hs, vs):
b.setRange(0, 16777215)
policies = [
(QSizePolicy.Fixed, "Fixed"),
(QSizePolicy.Minimum, "Minimum"),
(QSizePolicy.Maximum, "Maximum"),
(QSizePolicy.Preferred, "Preferred"),
(QSizePolicy.Expanding, "Expanding"),
(QSizePolicy.MinimumExpanding, "MinExpanding"),
(QSizePolicy.Ignored, "Ignored"),
]
for pol, text in policies:
h_combo.addItem(text, self._enum_int(pol))
v_combo.addItem(text, self._enum_int(pol))
def _set_current(combo, val):
idx = combo.findData(self._enum_int(val))
if idx >= 0:
combo.setCurrentIndex(idx)
_set_current(h_combo, sp.horizontalPolicy())
_set_current(v_combo, sp.verticalPolicy())
hs.setValue(sp.horizontalStretch())
vs.setValue(sp.verticalStretch())
def apply_changes():
hp = QSizePolicy.Policy(h_combo.currentData())
vp = QSizePolicy.Policy(v_combo.currentData())
nsp = QSizePolicy(hp, vp)
nsp.setHorizontalStretch(hs.value())
nsp.setVerticalStretch(vs.value())
self._target.setProperty(name, nsp)
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
hs.valueChanged.connect(lambda _=None: apply_changes())
vs.valueChanged.connect(lambda _=None: apply_changes())
row.addWidget(h_combo)
row.addWidget(v_combo)
row.addWidget(hs)
row.addWidget(vs)
return wrap
def _make_locale_editor(self, name: str, loc):
if not isinstance(loc, QLocale):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
lang_combo = QComboBox(wrap)
country_combo = QComboBox(wrap)
for lang in QLocale.Language:
try:
lang_int = self._enum_int(lang)
except Exception:
continue
if lang_int < 0:
continue
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
lang_combo.addItem(name_txt, lang_int)
def populate_countries():
country_combo.blockSignals(True)
country_combo.clear()
for terr in QLocale.Country:
try:
terr_int = self._enum_int(terr)
except Exception:
continue
if terr_int < 0:
continue
text = QLocale.countryToString(QLocale.Country(terr_int))
country_combo.addItem(text, terr_int)
cur_country = self._enum_int(loc.country())
idx = country_combo.findData(cur_country)
if idx >= 0:
country_combo.setCurrentIndex(idx)
country_combo.blockSignals(False)
cur_lang = self._enum_int(loc.language())
idx = lang_combo.findData(cur_lang)
if idx >= 0:
lang_combo.setCurrentIndex(idx)
populate_countries()
def apply_locale():
lang = QLocale.Language(int(lang_combo.currentData()))
country = QLocale.Country(int(country_combo.currentData()))
self._target.setProperty(name, QLocale(lang, country))
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
row.addWidget(lang_combo)
row.addWidget(country_combo)
return wrap
def _make_icon_editor(self, name: str, icon):
btn = QPushButton(self)
btn.setText("Choose…")
if isinstance(icon, QIcon) and not icon.isNull():
btn.setIcon(icon)
def pick():
path, _ = QFileDialog.getOpenFileName(
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
)
if path:
ic = QIcon(path)
self._target.setProperty(name, ic)
btn.setIcon(ic)
btn.clicked.connect(pick)
return btn
def _spin_pair(self, ints: bool = True):
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
if ints:
box1.setRange(-10_000_000, 10_000_000)
box2.setRange(-10_000_000, 10_000_000)
else:
for b in (box1, box2):
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
row.addWidget(box1)
row.addWidget(box2)
return wrap, box1, box2
def _spin_quad(self, ints: bool = True):
s = QSpinBox if ints else QDoubleSpinBox
boxes = [s(self) for _ in range(4)]
if ints:
for b in boxes:
b.setRange(-10_000_000, 10_000_000)
else:
for b in boxes:
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
for b in boxes:
row.addWidget(b)
return wrap, boxes
def _make_font_editor(self, name: str, value):
btn = QPushButton(self)
if isinstance(value, QFont):
btn.setText(f"{value.family()}, {value.pointSize()}pt")
else:
btn.setText("Select font…")
def pick():
ok, font = QFontDialog.getFont(
value if isinstance(value, QFont) else QFont(), self, "Select Font"
)
if ok:
self._target.setProperty(name, font)
btn.setText(f"{font.family()}, {font.pointSize()}pt")
btn.clicked.connect(pick)
return btn
def _make_color_editor(self, initial: QColor, apply_cb):
btn = QPushButton(self)
if isinstance(initial, QColor):
btn.setText(initial.name())
btn.setStyleSheet(f"background:{initial.name()};")
else:
btn.setText("Select color…")
def pick():
col = QColorDialog.getColor(
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
)
if col.isValid():
apply_cb(col)
btn.setText(col.name())
btn.setStyleSheet(f"background:{col.name()};")
btn.clicked.connect(pick)
return btn
def _apply_palette_color(
self,
name: str,
pal: QPalette,
group: QPalette.ColorGroup,
role: QPalette.ColorRole,
col: QColor,
):
pal.setColor(group, role, col)
self._target.setProperty(name, pal)
def _make_palette_editor(self, name: str, pal: QPalette):
if not isinstance(pal, QPalette):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
group_combo = QComboBox(wrap)
role_combo = QComboBox(wrap)
pick_btn = self._make_color_editor(
pal.color(QPalette.Active, QPalette.WindowText),
lambda col: self._apply_palette_color(
name, pal, QPalette.Active, QPalette.WindowText, col
),
)
groups = [
(QPalette.Active, "Active"),
(QPalette.Inactive, "Inactive"),
(QPalette.Disabled, "Disabled"),
]
for g, label in groups:
group_combo.addItem(label, int(getattr(g, "value", g)))
roles = [
(QPalette.WindowText, "WindowText"),
(QPalette.Window, "Window"),
(QPalette.Base, "Base"),
(QPalette.AlternateBase, "AlternateBase"),
(QPalette.ToolTipBase, "ToolTipBase"),
(QPalette.ToolTipText, "ToolTipText"),
(QPalette.Text, "Text"),
(QPalette.Button, "Button"),
(QPalette.ButtonText, "ButtonText"),
(QPalette.BrightText, "BrightText"),
(QPalette.Highlight, "Highlight"),
(QPalette.HighlightedText, "HighlightedText"),
]
for r, label in roles:
role_combo.addItem(label, int(getattr(r, "value", r)))
def rewire_button():
g = QPalette.ColorGroup(int(group_combo.currentData()))
r = QPalette.ColorRole(int(role_combo.currentData()))
col = pal.color(g, r)
while row.count() > 2:
w = row.takeAt(2).widget()
if w:
w.deleteLater()
btn = self._make_color_editor(
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
)
row.addWidget(btn)
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
row.addWidget(group_combo)
row.addWidget(role_combo)
row.addWidget(pick_btn)
return wrap
def _make_cursor_editor(self, name: str, value):
combo = QComboBox(self)
shapes = [
(Qt.ArrowCursor, "Arrow"),
(Qt.IBeamCursor, "IBeam"),
(Qt.WaitCursor, "Wait"),
(Qt.CrossCursor, "Cross"),
(Qt.UpArrowCursor, "UpArrow"),
(Qt.SizeAllCursor, "SizeAll"),
(Qt.PointingHandCursor, "PointingHand"),
(Qt.ForbiddenCursor, "Forbidden"),
(Qt.WhatsThisCursor, "WhatsThis"),
(Qt.BusyCursor, "Busy"),
]
current_shape = None
if isinstance(value, QCursor):
try:
enum_val = value.shape()
current_shape = int(getattr(enum_val, "value", enum_val))
except Exception:
current_shape = None
for shape, text in shapes:
combo.addItem(text, int(getattr(shape, "value", shape)))
if current_shape is not None:
idx = combo.findData(current_shape)
if idx >= 0:
combo.setCurrentIndex(idx)
def apply_index(i):
shape_val = int(combo.itemData(i))
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
combo.currentIndexChanged.connect(apply_index)
return combo
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
item = QTreeWidgetItem(parent, [name, ""])
editor = self._make_editor(name, value, prop)
if editor is not None:
self.tree.setItemWidget(item, 1, editor)
else:
item.setText(1, repr(value))
def _is_bec_metaobject(self, mo) -> bool:
cname = mo.className()
for cls in type(self._target).mro():
if getattr(cls, "__name__", None) == cname:
mod = getattr(cls, "__module__", "")
return mod.startswith("bec_widgets")
return False
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
if not meta_enum.isFlag():
key = meta_enum.valueToKey(value_int)
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
parts = []
for i in range(meta_enum.keyCount()):
k = meta_enum.key(i)
v = meta_enum.value(i)
if value_int & v:
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
parts.append(k)
return " | ".join(parts) if parts else "0"
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
try:
return int(value)
except Exception:
pass
v = getattr(value, "value", None)
if isinstance(v, (int,)):
return int(v)
n = getattr(value, "name", None)
if isinstance(n, str):
res = meta_enum.keyToValue(n)
if res != -1:
return int(res)
s = str(value)
parts = [p.strip() for p in s.replace(",", "|").split("|")]
keys = []
for p in parts:
if "." in p:
p = p.split(".")[-1]
keys.append(p)
keystr = "|".join(keys)
try:
res = meta_enum.keysToValue(keystr)
if res != -1:
return int(res)
except Exception:
pass
return 0
def _make_enum_editor(self, name: str, value, prop):
meta_enum = prop.enumerator()
current = self._enum_value_to_int(meta_enum, value)
if not meta_enum.isFlag():
combo = QComboBox(self)
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
combo.addItem(key, meta_enum.value(i))
idx = combo.findData(current)
if idx < 0:
txt = self._enum_text(meta_enum, current)
idx = combo.findText(txt)
combo.setCurrentIndex(max(idx, 0))
def apply_index(i):
v = combo.itemData(i)
self._target.setProperty(name, int(v))
combo.currentIndexChanged.connect(apply_index)
return combo
btn = QToolButton(self)
btn.setText(self._enum_text(meta_enum, current))
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
actions = []
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
act = menu.addAction(key)
act.setCheckable(True)
act.setChecked(bool(current & meta_enum.value(i)))
actions.append(act)
btn.setMenu(menu)
def apply_flags():
flags = 0
for i, act in enumerate(actions):
if act.isChecked():
flags |= meta_enum.value(i)
self._target.setProperty(name, int(flags))
btn.setText(self._enum_text(meta_enum, flags))
menu.triggered.connect(lambda _a: apply_flags())
return btn
def _make_editor(self, name: str, value, prop):
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
if prop.isEnumType():
return self._make_enum_editor(name, value, prop)
if isinstance(value, QColor):
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
if isinstance(value, QFont):
return self._make_font_editor(name, value)
if isinstance(value, QPalette):
return self._make_palette_editor(name, value)
if isinstance(value, QCursor):
return self._make_cursor_editor(name, value)
if isinstance(value, QSizePolicy):
ed = self._make_sizepolicy_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QLocale):
ed = self._make_locale_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QIcon):
ed = self._make_icon_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QSize):
wrap, w, h = self._spin_pair(ints=True)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
return wrap
if isinstance(value, QSizeF):
wrap, w, h = self._spin_pair(ints=False)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
return wrap
if isinstance(value, QPoint):
wrap, x, y = self._spin_pair(ints=True)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
return wrap
if isinstance(value, QPointF):
wrap, x, y = self._spin_pair(ints=False)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
return wrap
if isinstance(value, QRect):
wrap, boxes = self._spin_quad(ints=True)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rect():
self._target.setProperty(
name,
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rect())
return wrap
if isinstance(value, QRectF):
wrap, boxes = self._spin_quad(ints=False)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rectf():
self._target.setProperty(
name,
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rectf())
return wrap
if isinstance(value, bool):
w = QCheckBox(self)
w.setChecked(bool(value))
w.toggled.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, int) and not isinstance(value, bool):
w = QSpinBox(self)
w.setRange(-10_000_000, 10_000_000)
w.setValue(int(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, float):
w = QDoubleSpinBox(self)
w.setDecimals(6)
w.setRange(-1e12, 1e12)
w.setSingleStep(0.1)
w.setValue(float(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, str):
w = QLineEdit(self)
w.setText(value)
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
return w
return None
class DemoApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
layout = QHBoxLayout(self)
# Create a BECWidget instance example
waveform = self.create_waveform()
# property editor for the BECWidget
property_editor = PropertyEditor(waveform, show_only_bec=True)
layout.addWidget(waveform)
layout.addWidget(property_editor)
def create_waveform(self):
"""Create a new waveform widget."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
waveform = Waveform(parent=self)
waveform.title = "New Waveform"
waveform.x_label = "X Axis"
waveform.y_label = "Y Axis"
return waveform
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
demo = DemoApp()
demo.setWindowTitle("Property Editor Demo")
demo.resize(1200, 800)
demo.show()
sys.exit(app.exec())

View File

@@ -1,11 +1,12 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtCore import Property, Qt
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(QFrame):
# TODO this should be removed completely in favor of QSS styling, no time now
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
@@ -28,6 +29,9 @@ class RoundedFrame(QFrame):
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Ensure QSS can paint background/border on this widget
self.setAttribute(Qt.WA_StyledBackground, True)
# Create a layout for the frame
if orientation == "vertical":
self.layout = QVBoxLayout(self)
@@ -45,22 +49,10 @@ class RoundedFrame(QFrame):
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self.update_style()
def apply_theme(self, theme: str):
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if self.content_widget is not None and isinstance(
self.content_widget, pg.GraphicsLayoutWidget
):
self.content_widget.setBackground(self.background_color)
# Update background color based on the theme
if theme == "light":
self.background_color = "#e9ecef" # Subtle contrast for light mode
else:
self.background_color = "#141414" # Dark mode
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
self.update_style()
@Property(int)
@@ -77,34 +69,21 @@ class RoundedFrame(QFrame):
"""
Update the style of the frame based on the background color.
"""
if self.background_color:
self.setStyleSheet(
f"""
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
background-color: {self.background_color};
border-radius: {self._radius}; /* Rounded corners */
border-radius: {self._radius}px;
}}
"""
)
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
Automatically apply background, border, and axis styles to the PlotWidget.
Args:
border (str): Border style (e.g., 'none', '1px solid red').
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
"""
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
GraphicsLayoutWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
self.content_widget.setBackground(self.background_color)
self.content_widget.setStyleSheet("")
class ExampleApp(QWidget): # pragma: no cover
@@ -128,24 +107,14 @@ class ExampleApp(QWidget): # pragma: no cover
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
# Add to layout
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(plot1)
layout.addWidget(plot2)
self.setLayout(layout)
from qtpy.QtCore import QTimer
def change_theme():
rounded_plot1.apply_theme("light")
rounded_plot2.apply_theme("dark")
QTimer.singleShot(100, change_theme)
# Theme flip demo removed; global theming applies automatically
if __name__ == "__main__": # pragma: no cover

View File

@@ -13,3 +13,17 @@ def register_rpc_methods(cls):
if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name)
return cls
def rpc_timeout(timeout: float | None):
"""
Decorator to set a timeout for RPC methods.
The actual implementation of timeout handling is within the cli module. This decorator
is solely to inform the generate-cli command about the timeout value.
"""
def decorator(func):
func.__rpc_timeout__ = timeout # Store the timeout value in the function
return func
return decorator

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -229,6 +230,8 @@ class RPCServer:
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)
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:

View File

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

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import weakref
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Dict, Literal
@@ -10,7 +11,7 @@ from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtGui import QAction, QColor, QIcon # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -33,13 +34,39 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
def create_action_with_text(toolbar_action, toolbar: QToolBar):
"""
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.
"""
btn = QToolButton(parent=toolbar)
if getattr(toolbar_action, "label_text", None):
toolbar_action.action.setText(toolbar_action.label_text)
if getattr(toolbar_action, "tooltip", None):
toolbar_action.action.setToolTip(toolbar_action.tooltip)
btn.setToolTip(toolbar_action.tooltip)
btn.setDefaultAction(toolbar_action.action)
btn.setAutoRaise(True)
if toolbar_action.text_position == "beside":
btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
else:
btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
btn.setText(toolbar_action.label_text)
toolbar.addWidget(btn)
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
option.checkState = Qt.CheckState.Unchecked
class LongPressToolButton(QToolButton):
@@ -84,13 +111,15 @@ class ToolBarAction(ABC):
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
def __init__(
self, icon_path: str | None = None, tooltip: str | None = None, checkable: bool = False
):
self.icon_path = (
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
)
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.tooltip: str = tooltip or ""
self.checkable: bool = checkable
self.action: QAction
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
@@ -106,6 +135,11 @@ class ToolBarAction(ABC):
pass
class IconAction(ToolBarAction):
@abstractmethod
def get_icon(self) -> QIcon: ...
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
@@ -113,56 +147,91 @@ class SeparatorAction(ToolBarAction):
toolbar.addSeparator()
class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
class QtIconAction(IconAction):
def __init__(
self,
standard_icon,
tooltip=None,
checkable=False,
label_text: str | None = None,
text_position: Literal["beside", "under"] | None = None,
parent=None,
):
"""
Action with a standard Qt icon for the toolbar.
Args:
standard_icon: The standard icon from QStyle.
tooltip(str, optional): The tooltip for the action. Defaults to None.
checkable(bool, optional): Whether the action is checkable. Defaults to False.
label_text(str | None, optional): Optional label text to display beside or under the icon.
text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon.
parent(QWidget or None, optional): Parent widget for the underlying QAction.
"""
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
self.label_text = label_text
self.text_position = text_position
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
if self.label_text is not None:
create_action_with_text(toolbar_action=self, toolbar=toolbar)
else:
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
class MaterialIconAction(IconAction):
"""
Action with a Material icon for the toolbar.
Args:
icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
icon_name (str): The name of the Material icon.
tooltip (str): The tooltip for the action.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
Defaults to None.
label_text (str | None, optional): Optional label text to display beside or under the icon.
text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
icon_name: str,
tooltip: str,
*,
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
label_text: str | None = None,
text_position: Literal["beside", "under"] | None = None,
parent=None,
):
"""
MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon.
This enables per-action icon text without breaking the existing API.
"""
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name
self.filled = filled
self.color = color
self.label_text = label_text
self.text_position = text_position
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon: QIcon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
) # type: ignore
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
@@ -178,7 +247,10 @@ class MaterialIconAction(ToolBarAction):
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
if self.label_text is not None:
create_action_with_text(toolbar_action=self, toolbar=toolbar)
else:
toolbar.addAction(self.action)
def get_icon(self):
"""
@@ -195,11 +267,11 @@ class DeviceSelectionAction(ToolBarAction):
Action for selecting a device in a combobox.
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
label (str): The label for the combobox.
"""
def __init__(self, label: str | None = None, device_combobox=None):
def __init__(self, /, device_combobox: DeviceComboBox, label: str | None = None):
super().__init__()
self.label = label
self.device_combobox = device_combobox
@@ -221,7 +293,7 @@ class DeviceSelectionAction(ToolBarAction):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class SwitchableToolBarAction(ToolBarAction):
class SwitchableToolBarAction(IconAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
@@ -241,9 +313,9 @@ class SwitchableToolBarAction(ToolBarAction):
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
actions: Dict[str, IconAction],
initial_action: str | None = None,
tooltip: str | None = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
@@ -266,11 +338,11 @@ class SwitchableToolBarAction(ToolBarAction):
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
self.main_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
self.main_button.setCheckable(self.checkable)
default_action = self.actions[self.current_key]
self.main_button.setIcon(default_action.get_icon())
self.main_button.setToolTip(default_action.tooltip)
self.main_button.setToolTip(default_action.tooltip or "")
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
@@ -368,11 +440,7 @@ class WidgetAction(ToolBarAction):
"""
def __init__(
self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
parent=None,
self, *, widget: QWidget, label: str | None = None, adjust_size: bool = True, parent=None
):
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
@@ -395,14 +463,14 @@ class WidgetAction(ToolBarAction):
if self.label is not None:
label_widget = QLabel(text=f"{self.label}", parent=target)
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
label_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
label_widget.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox) and self.adjust_size:
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
self.widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
@@ -411,7 +479,7 @@ class WidgetAction(ToolBarAction):
toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.container
self.action = self.container # type: ignore
def cleanup(self):
"""
@@ -426,7 +494,7 @@ class WidgetAction(ToolBarAction):
@staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int:
font_metrics = combo_box.fontMetrics()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) # type: ignore
return max_width + 60
@@ -440,16 +508,20 @@ class ExpandableMenuAction(ToolBarAction):
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
def __init__(self, label: str, actions: dict[str, IconAction], icon_path: str | None = None):
super().__init__(icon_path, label)
self.actions = actions
self._button_ref: weakref.ReferenceType[QToolButton] | None = None
self._menu_ref: weakref.ReferenceType[QMenu] | None = None
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
button.setObjectName("toolbarMenuButton")
button.setAutoRaise(True)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
@@ -476,6 +548,14 @@ class ExpandableMenuAction(ToolBarAction):
menu.addAction(action)
button.setMenu(menu)
toolbar.addWidget(button)
self._button_ref = weakref.ref(button)
self._menu_ref = weakref.ref(menu)
def get_toolbar_button(self) -> QToolButton | None:
return self._button_ref() if self._button_ref else None
def get_menu(self) -> QMenu | None:
return self._menu_ref() if self._menu_ref else None
class DeviceComboBoxAction(WidgetAction):
@@ -522,3 +602,76 @@ class DeviceComboBoxAction(WidgetAction):
self.combobox.close()
self.combobox.deleteLater()
return super().cleanup()
class TutorialAction(MaterialIconAction):
"""
Action for starting a guided tutorial/help tour.
This action automatically initializes a GuidedTour instance and provides
methods to register widgets and start tours.
Args:
main_window (QWidget): The main window widget for the guided tour overlay.
tooltip (str, optional): The tooltip for the action. Defaults to "Start Guided Tutorial".
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=None):
super().__init__(
icon_name="help",
tooltip=tooltip,
checkable=False,
filled=False,
color=None,
parent=parent,
)
from bec_widgets.utils.guided_tour import GuidedTour
self.guided_help = GuidedTour(main_window)
self.main_window = main_window
# Connect the action to start the tour
self.action.triggered.connect(self.start_tour)
def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> str:
"""
Register a widget for the guided tour.
Args:
widget (QWidget): The widget to highlight during the tour.
text (str): The help text to display.
widget_name (str, optional): Optional name for the widget.
Returns:
str: Unique ID for the registered widget.
"""
return self.guided_help.register_widget(widget=widget, text=text, title=widget_name)
def start_tour(self):
"""Start the guided tour with all registered widgets."""
registered_widgets = self.guided_help.get_registered_widgets()
if registered_widgets:
# Create tour from all registered widgets
step_ids = list(registered_widgets.keys())
if self.guided_help.create_tour(step_ids):
self.guided_help.start_tour()
else:
logger.warning("Failed to create guided tour")
else:
logger.warning("No widgets registered for guided tour")
def has_registered_widgets(self) -> bool:
"""Check if any widgets have been registered for the tour."""
return len(self.guided_help.get_registered_widgets()) > 0
def clear_registered_widgets(self):
"""Clear all registered widgets."""
self.guided_help.clear_registrations()
def cleanup(self):
"""Clean up the guided help instance."""
if hasattr(self, "guided_help"):
self.guided_help.stop_tour()
super().cleanup()

View File

@@ -6,11 +6,11 @@ from collections import defaultdict
from typing import DefaultDict, Literal
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
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 bec_widgets.utils.colors import get_theme_name, set_theme
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.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -492,10 +492,33 @@ if __name__ == "__main__": # pragma: no cover
self.toolbar.connect_bundle(
"base", PerformanceConnection(self.toolbar.components, self)
)
self.toolbar.components.add_safe(
"text",
MaterialIconAction(
"text_fields",
tooltip="Test Text Action",
checkable=True,
label_text="text",
text_position="under",
),
)
self.toolbar.show_bundles(["performance", "plot_export"])
self.toolbar.get_bundle("performance").add_action("save")
self.toolbar.get_bundle("performance").add_action("text")
self.toolbar.refresh()
# Timer to disable and enable text button each 2s
self.timer = QTimer()
self.timer.timeout.connect(self.toggle_text_action)
self.timer.start(2000)
def toggle_text_action(self):
text_action = self.toolbar.components.get_action("text")
if text_action.action.isEnabled():
text_action.action.setEnabled(False)
else:
text_action.action.setEnabled(True)
def enable_fps_monitor(self, enabled: bool):
"""
Example method to enable or disable FPS monitoring.
@@ -507,7 +530,7 @@ if __name__ == "__main__": # pragma: no cover
self.test_label.setText("FPS Monitor Disabled")
app = QApplication(sys.argv)
set_theme("light")
apply_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -2,8 +2,10 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Type, TypeVar, cast
import shiboken6 as shb
from bec_lib import bec_logger
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -21,6 +23,13 @@ from qtpy.QtWidgets import (
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils import BECConnector
logger = bec_logger.logger
TAncestor = TypeVar("TAncestor", bound=QWidget)
class WidgetHandler(ABC):
"""Abstract base class for all widget handlers."""
@@ -465,13 +474,19 @@ class WidgetHierarchy:
"""
from bec_widgets.utils import BECConnector
# Guard against deleted/invalid Qt wrappers
if not shb.isValid(widget):
return None
parent = widget.parent()
# Retrieve first parent
parent = widget.parent() if hasattr(widget, "parent") else None
# Walk up, validating each step
while parent is not None:
if not shb.isValid(parent):
return None
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
parent = parent.parent() if hasattr(parent, "parent") else None
return None
@staticmethod
@@ -553,6 +568,70 @@ class WidgetHierarchy:
WidgetIO.set_value(child, value)
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
@staticmethod
def get_bec_connectors_from_parent(widget) -> list:
"""
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
including the widget itself if it is a BECConnector.
"""
from bec_widgets.utils import BECConnector
connectors: list[BECConnector] = []
if isinstance(widget, BECConnector):
connectors.append(widget)
for child in widget.findChildren(BECConnector):
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
connectors.append(child)
return connectors
@staticmethod
def find_ancestor(
widget: QWidget | BECConnector, ancestor_class: Type[TAncestor] | str
) -> TAncestor | None:
"""
Find the closest ancestor of the specified class (or class-name string).
Args:
widget(QWidget): The starting widget.
ancestor_class(Type[TAncestor] | str): The ancestor class or class-name string to search for.
Returns:
TAncestor | None: The closest ancestor of the specified class, or None if not found.
"""
if widget is None or not shb.isValid(widget):
return None
try:
from bec_widgets.utils import BECConnector # local import to avoid cycles
is_bec_target = False
if isinstance(ancestor_class, str):
is_bec_target = ancestor_class == "BECConnector"
elif isinstance(ancestor_class, type):
is_bec_target = issubclass(ancestor_class, BECConnector)
if is_bec_target:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
return cast(TAncestor, ancestor)
except Exception as e:
logger.error(f"Error importing BECConnector: {e}")
parent = widget.parent() if hasattr(widget, "parent") else None
while parent is not None:
if not shb.isValid(parent):
return None
try:
if isinstance(ancestor_class, str):
if parent.__class__.__name__ == ancestor_class:
return cast(TAncestor, parent)
else:
if isinstance(parent, ancestor_class):
return cast(TAncestor, parent)
except Exception as e:
logger.error(f"Error checking ancestor class: {e}")
parent = parent.parent() if hasattr(parent, "parent") else None
return None
# Example usage
def hierarchy_example(): # pragma: no cover

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import shiboken6
from bec_lib import bec_logger
from qtpy.QtCore import QSettings
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -15,58 +17,89 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"]
class WidgetStateManager:
"""
A class to manage the state of a widget by saving and loading the state to and from a INI file.
Manage saving and loading widget state to/from an INI file.
Args:
widget(QWidget): The widget to manage the state for.
widget (QWidget): Root widget whose subtree will be serialized.
serialize_from_root (bool): When True, build group names relative to
this root and ignore parents above it. This keeps profiles portable
between different host window hierarchies.
root_id (str | None): Optional stable label to use for the root in
the settings key path. When omitted and `serialize_from_root` is
True, the class name of `widget` is used, falling back to its
objectName and finally to "root".
"""
def __init__(self, widget):
def __init__(self, widget, *, serialize_from_root: bool = False, root_id: str | None = None):
self.widget = widget
self._serialize_from_root = bool(serialize_from_root)
self._root_id = root_id
def save_state(self, filename: str = None):
def save_state(self, filename: str | None = None, settings: QSettings | None = None):
"""
Save the state of the widget to an INI file.
Args:
filename(str): The filename to save the state to.
settings(QSettings): Optional QSettings object to save the state to.
"""
if not filename:
if not filename and not settings:
filename, _ = QFileDialog.getSaveFileName(
self.widget, "Save Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._save_widget_state_qsettings(self.widget, settings)
elif settings:
# If settings are provided, save the state to the provided QSettings object
self._save_widget_state_qsettings(self.widget, settings)
else:
logger.warning("No filename or settings provided for saving state.")
def load_state(self, filename: str = None):
def load_state(self, filename: str | None = None, settings: QSettings | None = None):
"""
Load the state of the widget from an INI file.
Args:
filename(str): The filename to load the state from.
settings(QSettings): Optional QSettings object to load the state from.
"""
if not filename:
if not filename and not settings:
filename, _ = QFileDialog.getOpenFileName(
self.widget, "Load Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._load_widget_state_qsettings(self.widget, settings)
elif settings:
# If settings are provided, load the state from the provided QSettings object
self._load_widget_state_qsettings(self.widget, settings)
else:
logger.warning("No filename or settings provided for saving state.")
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
def _save_widget_state_qsettings(
self, widget: QWidget, settings: QSettings, recursive: bool = True
):
"""
Save the state of the widget to QSettings.
Args:
widget(QWidget): The widget to save the state for.
settings(QSettings): The QSettings object to save the state to.
recursive(bool): Whether to recursively save the state of child widgets.
"""
if widget is None or not shiboken6.isValid(widget):
return
if widget.property("skip_settings") is True:
return
@@ -76,34 +109,64 @@ class WidgetStateManager:
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
# Skip persisting QWidget visibility because container widgets (e.g. tab
# stacks, dock managers) manage that state themselves. Restoring a saved
# False can permanently hide a widget, while forcing True makes hidden
# tabs show on top. Leave the property to the parent widget instead.
if name == "visible":
continue
if (
name == "objectName"
or name in PROPERTY_TO_SKIP
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored() # can be extended to fine filter
):
continue
value = widget.property(name)
if isinstance(value, QIcon):
continue
settings.setValue(name, value)
settings.endGroup()
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if not recursive:
return
direct_children = widget.children()
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
all_children = list(
set(direct_children) | set(bec_connector_children)
) # to avoid duplicates
for child in all_children:
if (
child.objectName()
child
and shiboken6.isValid(child)
and child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._save_widget_state_qsettings(child, settings)
self._save_widget_state_qsettings(child, settings, False)
logger.info(f"Saved state for widget '{widget_name}'")
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
def _load_widget_state_qsettings(
self, widget: QWidget, settings: QSettings, recursive: bool = True
):
"""
Load the state of the widget from QSettings.
Args:
widget(QWidget): The widget to load the state for.
settings(QSettings): The QSettings object to load the state from.
recursive(bool): Whether to recursively load the state of child widgets.
"""
if widget is None or not shiboken6.isValid(widget):
return
if widget.property("skip_settings") is True:
return
@@ -113,37 +176,76 @@ class WidgetStateManager:
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if name == "visible":
continue
if settings.contains(name):
value = settings.value(name)
widget.setProperty(name, value)
settings.endGroup()
if not recursive:
return
# Recursively process children (only if they aren't skipped)
for child in widget.children():
direct_children = widget.children()
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
all_children = list(
set(direct_children) | set(bec_connector_children)
) # to avoid duplicates
for child in all_children:
if (
child.objectName()
child
and shiboken6.isValid(child)
and child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._load_widget_state_qsettings(child, settings)
self._load_widget_state_qsettings(child, settings, False)
def _get_full_widget_name(self, widget: QWidget):
def _get_full_widget_name(self, widget: QWidget) -> str:
"""
Get the full name of the widget including its parent names.
Build a group key for *widget*.
When `serialize_from_root` is False (default), this preserves the original
behavior and walks all parents up to the top-level widget.
When `serialize_from_root` is True, the key is built relative to
`self.widget` and parents above the managed root are ignored. The first
path segment is either `root_id` (when provided) or a stable label derived
from the root widget (class name, then objectName, then "root").
Args:
widget(QWidget): The widget to get the full name for.
Returns:
str: The full name of the widget.
widget (QWidget): The widget to build the key for.
"""
name = widget.objectName()
parent = widget.parent()
while parent:
obj_name = parent.objectName() or parent.metaObject().className()
name = obj_name + "." + name
parent = parent.parent()
return name
# Backwards-compatible behavior: include the entire parent chain.
if not getattr(self, "_serialize_from_root", False):
name = widget.objectName()
parent = widget.parent()
while parent:
obj_name = parent.objectName() or parent.metaObject().className()
name = obj_name + "." + name
parent = parent.parent()
return name
parts: list[str] = []
current: QWidget | None = widget
while current is not None:
if current is self.widget:
# Reached the serialization root.
root_label = self._root_id
if not root_label:
meta = current.metaObject() if hasattr(current, "metaObject") else None
class_name = meta.className() if meta is not None else ""
root_label = class_name or current.objectName() or "root"
parts.append(str(root_label))
break
obj_name = current.objectName() or current.metaObject().className()
parts.append(obj_name)
current = current.parent()
parts.reverse()
return ".".join(parts)
class ExampleApp(QWidget): # pragma: no cover:

View File

@@ -0,0 +1,971 @@
from __future__ import annotations
import os
from typing import Callable, Literal, Mapping, Sequence
from bec_lib import bec_logger
from qtpy.QtCore import QTimer, Signal
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
QDialog,
QInputDialog,
QMessageBox,
QSizePolicy,
QVBoxLayout,
QWidget,
)
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot
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.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_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 (
SETTINGS_KEYS,
default_profile_candidates,
delete_profile_files,
get_last_profile,
is_profile_read_only,
is_quick_select,
list_quick_profiles,
load_default_profile_screenshot,
load_user_profile_screenshot,
now_iso_utc,
open_default_settings,
open_user_settings,
profile_origin,
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 (
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 (
WorkspaceConnection,
workspace_bundle,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
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.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.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
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
_PROFILE_NAMESPACE_UNSET = object()
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
class AdvancedDockArea(DockAreaWidget):
RPC = True
PLUGIN = False
USER_ACCESS = [
"new",
"dock_map",
"dock_list",
"widget_map",
"widget_list",
"lock_workspace",
"attach_all",
"delete_all",
"set_layout_ratios",
"describe_layout",
"print_layout_structure",
"mode",
"mode.setter",
]
# Define a signal for mode changes
mode_changed = Signal(str)
profile_changed = Signal(str)
def __init__(
self,
parent=None,
mode: Literal["plot", "device", "utils", "user", "creator"] = "creator",
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
profile_namespace: str | None = None,
auto_profile_namespace: bool = True,
instance_id: str | None = None,
auto_save_upon_exit: bool = True,
enable_profile_management: bool = True,
restore_initial_profile: bool = True,
**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._auto_save_upon_exit = auto_save_upon_exit
self._profile_management_enabled = enable_profile_management
self._restore_initial_profile = restore_initial_profile
super().__init__(
parent,
default_add_direction=default_add_direction,
title="Advanced Dock Area",
**kwargs,
)
# Initialize mode property first (before toolbar setup)
self._mode = mode
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dark_mode_button.setVisible(enable_profile_management)
self._setup_toolbar()
self._hook_toolbar()
# Popups
self.save_dialog = None
self.manage_dialog = None
# Place toolbar above the dock manager provided by the base class
self._root_layout.insertWidget(0, self.toolbar)
# Populate and hook the workspace combo
self._refresh_workspace_list()
self._current_profile_name = None
self._pending_autosave_skip: tuple[str, str] | None = None
self._exit_snapshot_written = False
# State manager
self.state_manager = WidgetStateManager(
self, serialize_from_root=True, root_id="AdvancedDockArea"
)
# Developer mode state
self._editable = None
# 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()
def _fetch_initial_profile(self):
# Restore last-used profile if available; otherwise fall back to combo selection
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))
def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile after construction when the event loop is running."""
self.load_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
combo.setCurrentText(name)
combo.blockSignals(False)
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
prefs = getattr(dock, "_dock_preferences", {}) or {}
if prefs.get("show_settings_action") is None:
prefs = dict(prefs)
prefs["show_settings_action"] = True
dock._dock_preferences = prefs
super()._customize_dock(dock, widget)
@SafeSlot(popup_error=True)
def new(
self,
widget: QWidget | str,
*,
closable: bool = True,
floatable: bool = True,
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,
**widget_kwargs,
) -> QWidget | CDockWidget | BECWidget:
"""
Override the base helper so dock settings are available by default.
The flag remains user-configurable (pass ``False`` to hide the action).
"""
if show_settings_action is None:
show_settings_action = True
return super().new(
widget,
closable=closable,
floatable=floatable,
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,
**widget_kwargs,
)
def _apply_dock_lock(self, locked: bool) -> None:
if locked:
self.dock_manager.lockDockWidgetFeaturesGlobally()
else:
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
################################################################################
# Toolbar Setup
################################################################################
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
PLOT_ACTIONS = {
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
"scatter_waveform": (
ScatterWaveform.ICON_NAME,
"Add Scatter Waveform",
"ScatterWaveform",
),
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
"image": (Image.ICON_NAME, "Add Image", "Image"),
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
}
DEVICE_ACTIONS = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
"positioner_box_2D": (
PositionerBox2D.ICON_NAME,
"Add Device 2D Box",
"PositionerBox2D",
),
}
UTIL_ACTIONS = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
RingProgressBar.ICON_NAME,
"Add Circular ProgressBar",
"RingProgressBar",
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
# Create expandable menu actions (original behavior)
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
self.toolbar.components.add_safe(
key,
ExpandableMenuAction(
label=label,
actions={
k: MaterialIconAction(
icon_name=v[0], tooltip=v[1], filled=True, parent=self
)
for k, v in mapping.items()
},
),
)
b = ToolbarBundle(key, self.toolbar.components)
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)
# Create flat toolbar bundles for each widget type
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
# Create individual action for each widget type
flat_action_id = f"flat_{action_id}"
self.toolbar.components.add_safe(
flat_action_id,
MaterialIconAction(
icon_name=icon_name,
tooltip=tooltip,
filled=True,
parent=self,
label_text=widget_type,
text_position="under",
),
)
bundle.add_action(flat_action_id)
self.toolbar.add_bundle(bundle)
_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)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
spacer_bundle.add_action("spacer")
self.toolbar.add_bundle(spacer_bundle)
self.toolbar.add_bundle(
workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled)
)
self.toolbar.connect_bundle(
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
)
# Dock actions
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.get_action("attach_all").action.setVisible(
self._profile_management_enabled
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
self.toolbar.components.get_action("screenshot").action.setVisible(
self._profile_management_enabled
)
dark_mode_action = WidgetAction(
widget=self.dark_mode_button, adjust_size=False, parent=self
)
dark_mode_action.widget.setVisible(self._profile_management_enabled)
self.toolbar.components.add_safe("dark_mode", dark_mode_action)
bda = ToolbarBundle("dock_actions", self.toolbar.components)
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,
}
def _hook_toolbar(self):
def _connect_menu(menu_key: str):
menu = self.toolbar.components.get_action(menu_key)
mapping = self._ACTION_MAPPINGS[menu_key]
# first two items not needed for this part
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
)
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,
)
)
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_menu("menu_plots")
_connect_menu("menu_devices")
_connect_menu("menu_utils")
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_utils"])
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self._editable = editable
if self._profile_management_enabled:
self.toolbar.components.get_action("attach_all").action.setVisible(editable)
def _on_developer_mode_toggled(self, checked: bool) -> None:
"""Handle developer mode checkbox toggle."""
self._set_editable(checked)
################################################################################
# Workspace Management
################################################################################
@SafeProperty(bool)
def lock_workspace(self) -> bool:
"""
Get or set the lock state of the workspace.
Returns:
bool: True if the workspace is locked, False otherwise.
"""
return self._locked
@lock_workspace.setter
def lock_workspace(self, value: bool):
"""
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
Args:
value (bool): True to lock the workspace, False to unlock it.
"""
self._locked = value
self._apply_dock_lock(value)
if self._profile_management_enabled:
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
for dock in self.dock_list():
dock.setting_action.setVisible(not value)
def _last_profile_instance_id(self) -> str | None:
"""
Identifier used to scope the last-profile entry for this dock area.
When unset, profiles are scoped only by namespace.
"""
return self._instance_id
def _resolve_profile_namespace(self) -> str | None:
if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET:
return self._profile_namespace_resolved # type: ignore[return-value]
candidate = self._profile_namespace_hint
if self._profile_namespace_auto:
if not candidate:
obj_name = self.objectName()
candidate = obj_name if obj_name else None
if not candidate:
title = self.windowTitle()
candidate = title if title and title.strip() else None
if not candidate:
mode_name = getattr(self, "_mode", None) or "creator"
candidate = f"{mode_name}_workspace"
if not candidate:
candidate = self.__class__.__name__
resolved = sanitize_namespace(candidate) if candidate else None
if not resolved:
resolved = "general"
self._profile_namespace_resolved = resolved # type: ignore[assignment]
return resolved
@property
def profile_namespace(self) -> str | None:
"""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)
) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace))
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
"""
Write the current workspace snapshot to the provided settings object.
Args:
settings(QSettings): The settings object to write to.
save_preview(bool): Whether to save a screenshot preview.
"""
self.save_to_settings(settings, keys=PROFILE_STATE_KEYS)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
if save_preview:
ba = self.screenshot_bytes()
if ba and len(ba) > 0:
settings.setValue(SETTINGS_KEYS["screenshot"], ba)
settings.setValue(SETTINGS_KEYS["screenshot_at"], now_iso_utc())
logger.info(f"Workspace snapshot written to settings: {settings.fileName()}")
@SafeSlot(str)
def save_profile(self, name: str | None = None):
"""
Save the current workspace profile.
On first save of a given name:
- writes a default copy to states/default/<name>.ini with tag=default and created_at
- writes a user copy to states/user/<name>.ini with tag=user and created_at
On subsequent saves of user-owned profiles:
- updates both the default and user copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten.
Args:
name (str | None): The name of the profile to save. If None, prompts the user.
"""
namespace = self.profile_namespace
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
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
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
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
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)
# 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)
self._refresh_workspace_list()
if current_profile and current_profile != name and not dialog.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)
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.
"""
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:"
)
if not ok or not name:
return
namespace = self.profile_namespace
prev_name = getattr(self, "_current_profile_name", None)
skip_pair = getattr(self, "_pending_autosave_skip", None)
if prev_name and prev_name != name:
if skip_pair and skip_pair == (prev_name, name):
self._pending_autosave_skip = None
else:
us_prev = open_user_settings(prev_name, namespace=namespace)
self._write_snapshot_to_settings(us_prev, save_preview=True)
settings = None
if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)):
settings = open_user_settings(name, namespace=namespace)
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.")
return
# Rebuild widgets and restore states
for item in read_manifest(settings):
obj_name = item["object_name"]
widget_class = item["widget_class"]
if obj_name not in self.widget_map():
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
w.setObjectName(obj_name)
floating_state = None
if item.get("floating"):
floating_state = {
"relative": item.get("floating_relative"),
"absolute": item.get("floating_absolute"),
"screen_name": item.get("floating_screen"),
}
self._make_dock(
w,
closable=item["closable"],
floatable=item["floatable"],
movable=item["movable"],
start_floating=item.get("floating", False),
floating_state=floating_state,
area=QtAds.DockWidgetArea.RightDockWidgetArea,
)
self.load_from_settings(settings, keys=PROFILE_STATE_KEYS)
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)
@SafeSlot()
@SafeSlot(str)
def restore_user_profile_from_default(self, name: str | None = None):
"""
Overwrite the user copy of *name* with the default baseline.
If *name* is None, target the currently active profile.
Args:
name (str | None): The name of the profile to restore. If None, uses the current profile.
"""
target = name or getattr(self, "_current_profile_name", None)
if not target:
return
namespace = self.profile_namespace
current_pixmap = None
if self.isVisible():
current_pixmap = QPixmap()
ba = bytes(self.screenshot_bytes())
current_pixmap.loadFromData(ba)
if current_pixmap is None or current_pixmap.isNull():
current_pixmap = load_user_profile_screenshot(target, namespace=namespace)
default_pixmap = load_default_profile_screenshot(target, namespace=namespace)
if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
return
restore_user_from_default(target, namespace=namespace)
self.delete_all()
self.load_profile(target)
@SafeSlot()
def delete_profile(self):
"""
Delete the currently selected workspace profile file and refresh the combo list.
"""
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
namespace = self.profile_namespace
delete_profile_files(name, namespace=namespace)
self._refresh_workspace_list()
def _refresh_workspace_list(self):
"""
Populate the workspace combo box with all saved profile names (without .ini).
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
active_profile = getattr(self, "_current_profile_name", None)
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)
else:
# Fallback for regular QComboBox
combo.blockSignals(True)
combo.clear()
quick_profiles = list_quick_profiles(namespace=namespace)
items = list(quick_profiles)
if active_profile and active_profile not in items:
items.insert(0, active_profile)
combo.addItems(items)
if active_profile:
idx = combo.findText(active_profile)
if idx >= 0:
combo.setCurrentIndex(idx)
if active_profile and active_profile not in quick_profiles:
combo.setToolTip("Active profile is not in quick select")
else:
combo.setToolTip("")
combo.blockSignals(False)
################################################################################
# Dialog Popups
################################################################################
@SafeSlot()
def show_workspace_manager(self):
"""
Show the workspace manager dialog.
"""
manage_action = self.toolbar.components.get_action("manage_workspaces").action
if self.manage_dialog is None or not self.manage_dialog.isVisible():
self.manage_widget = WorkSpaceManager(
self, target_widget=self, default_profile=self._current_profile_name
)
self.manage_dialog = QDialog(modal=False)
self.manage_dialog.setWindowTitle("Workspace Manager")
self.manage_dialog.setMinimumSize(1200, 500)
self.manage_dialog.layout = QVBoxLayout(self.manage_dialog)
self.manage_dialog.layout.addWidget(self.manage_widget)
self.manage_dialog.finished.connect(self._manage_dialog_closed)
self.manage_dialog.show()
self.manage_dialog.resize(300, 300)
manage_action.setChecked(True)
else:
# If already open, bring it to the front
self.manage_dialog.raise_()
self.manage_dialog.activateWindow()
manage_action.setChecked(True) # keep it toggle
def _manage_dialog_closed(self):
self.manage_widget.close()
self.manage_widget.deleteLater()
if self.manage_dialog is not None:
self.manage_dialog.deleteLater()
self.manage_dialog = None
self.toolbar.components.get_action("manage_workspaces").action.setChecked(False)
################################################################################
# Mode Switching
################################################################################
@SafeProperty(str)
def mode(self) -> str:
return self._mode
@mode.setter
def mode(self, new_mode: str):
allowed_modes = ["plot", "device", "utils", "user", "creator"]
if new_mode not in allowed_modes:
raise ValueError(f"Invalid mode: {new_mode}")
self._mode = new_mode
self.mode_changed.emit(new_mode)
self._apply_toolbar_layout()
def _apply_toolbar_layout(self) -> None:
mode_key = getattr(self, "_mode", "creator")
if mode_key == "user":
bundles = ["spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "creator":
bundles = [
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
elif mode_key == "plot":
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "device":
bundles = ["flat_devices", "spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "utils":
bundles = ["flat_utils", "spacer_bundle", "workspace", "dock_actions"]
else:
bundles = ["spacer_bundle", "workspace", "dock_actions"]
if not self._profile_management_enabled:
flat_only = [b for b in bundles if b.startswith("flat_")]
if not flat_only:
flat_only = ["flat_plots", "flat_devices", "flat_utils"]
bundles = flat_only
self.toolbar.show_bundles(bundles)
def prepare_for_shutdown(self) -> None:
"""
Persist the current workspace snapshot while the UI is still fully visible.
Called by the main window before initiating widget teardown to avoid capturing
close-triggered visibility changes.
"""
if (
not self._auto_save_upon_exit
or getattr(self, "_exit_snapshot_written", False)
or getattr(self, "_destroyed", False)
):
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
return
name = self._active_profile_name_or_default()
namespace = self.profile_namespace
settings = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(settings)
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
self._exit_snapshot_written = True
def cleanup(self):
"""
Cleanup the dock area.
"""
self.prepare_for_shutdown()
if self.manage_dialog is not None:
self.manage_dialog.reject()
self.manage_dialog = None
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.toolbar.cleanup()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QTabWidget
app = QApplication(sys.argv)
apply_theme("dark")
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
central = QWidget()
layout = QVBoxLayout(central)
window.setCentralWidget(central)
# two dock areas stacked vertically no instance ids
ads = AdvancedDockArea(mode="creator", enable_profile_management=True)
ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True)
layout.addWidget(ads, 1)
layout.addWidget(ads2, 1)
# two dock areas inside a tab widget
tabs = QTabWidget(parent=central)
ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3")
ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4")
tabs.addTab(ads3, "Workspace 3")
tabs.addTab(ads4, "Workspace 4")
layout.addWidget(tabs, 1)
window.show()
window.resize(800, 1000)
sys.exit(app.exec())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
from __future__ import annotations
from typing import Callable, Literal
from qtpy.QtCore import Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QCheckBox,
QDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeSlot
class SaveProfileDialog(QDialog):
"""Dialog for saving workspace profiles with quick select option."""
def __init__(
self,
parent: QWidget | None = None,
current_name: str = "",
current_profile_name: str = "",
*,
name_exists: Callable[[str], bool] | None = None,
profile_origin: (
Callable[[str], Literal["module", "plugin", "settings", "unknown"]] | None
) = None,
origin_label: Callable[[str], str | None] | None = None,
quick_select_checked: bool = False,
):
super().__init__(parent)
self.setWindowTitle("Save Workspace Profile")
self.setModal(True)
self.resize(400, 160)
self._name_exists = name_exists or (lambda _: False)
self._profile_origin = profile_origin or (lambda _: "unknown")
self._origin_label = origin_label or (lambda _: None)
self._current_profile_name = current_profile_name.strip()
self._previous_name_before_overwrite = current_name
self._block_name_signals = False
self._block_checkbox_signals = False
self.overwrite_existing = False
layout = QVBoxLayout(self)
# Name input
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Profile Name:"))
self.name_edit = QLineEdit(current_name)
self.name_edit.setPlaceholderText("Enter profile name...")
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# Overwrite checkbox
self.overwrite_checkbox = QCheckBox("Overwrite current profile")
self.overwrite_checkbox.setEnabled(bool(self._current_profile_name))
self.overwrite_checkbox.toggled.connect(self._on_overwrite_toggled)
layout.addWidget(self.overwrite_checkbox)
# Quick-select checkbox
self.quick_select_checkbox = QCheckBox("Include in quick selection.")
self.quick_select_checkbox.setChecked(quick_select_checked)
layout.addWidget(self.quick_select_checkbox)
# Buttons
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.save_btn = QPushButton("Save")
self.save_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
self.save_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Enable/disable save button based on name input
self.name_edit.textChanged.connect(self._on_name_changed)
self._update_save_button()
@SafeSlot(bool)
def _on_overwrite_toggled(self, checked: bool):
if self._block_checkbox_signals:
return
if not self._current_profile_name:
return
self._block_name_signals = True
if checked:
self._previous_name_before_overwrite = self.name_edit.text()
self.name_edit.setText(self._current_profile_name)
self.name_edit.selectAll()
else:
if self.name_edit.text().strip() == self._current_profile_name:
self.name_edit.setText(self._previous_name_before_overwrite or "")
self._block_name_signals = False
self._update_save_button()
@SafeSlot(str)
def _on_name_changed(self, _: str):
if self._block_name_signals:
return
text = self.name_edit.text().strip()
if self.overwrite_checkbox.isChecked() and text != self._current_profile_name:
self._block_checkbox_signals = True
self.overwrite_checkbox.setChecked(False)
self._block_checkbox_signals = False
self._update_save_button()
def _update_save_button(self):
"""Enable save button only when name is not empty."""
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
def get_profile_name(self) -> str:
"""Return the entered profile name."""
return self.name_edit.text().strip()
def is_quick_select(self) -> bool:
"""Return whether the profile should appear in quick select."""
return self.quick_select_checkbox.isChecked()
def _generate_unique_name(self, base: str) -> str:
candidate_base = base.strip() or "profile"
suffix = "_custom"
candidate = f"{candidate_base}{suffix}"
counter = 1
while self._name_exists(candidate) or self._profile_origin(candidate) != "unknown":
candidate = f"{candidate_base}{suffix}_{counter}"
counter += 1
return candidate
def accept(self):
name = self.get_profile_name()
if not name:
return
self.overwrite_existing = False
origin = self._profile_origin(name)
if origin in {"module", "plugin"}:
source_label = self._origin_label(name)
if origin == "module":
provider = source_label or "BEC Widgets"
else:
provider = (
f"the {source_label} plugin repository"
if source_label
else "the plugin repository"
)
QMessageBox.information(
self,
"Read-only profile",
(
f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n"
"Please choose a different name."
),
)
suggestion = self._generate_unique_name(name)
self._block_name_signals = True
self.name_edit.setText(suggestion)
self.name_edit.selectAll()
self._block_name_signals = False
self._block_checkbox_signals = True
self.overwrite_checkbox.setChecked(False)
self._block_checkbox_signals = False
return
if origin == "settings":
reply = QMessageBox.question(
self,
"Overwrite profile",
(
f"A profile named '{name}' already exists.\n\n"
"Overwriting will update both the saved profile and its restore default.\n"
"Do you want to continue?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
suggestion = self._generate_unique_name(name)
self._block_name_signals = True
self.name_edit.setText(suggestion)
self.name_edit.selectAll()
self._block_name_signals = False
self._block_checkbox_signals = True
self.overwrite_checkbox.setChecked(False)
self._block_checkbox_signals = False
return
self.overwrite_existing = True
super().accept()
class PreviewPanel(QGroupBox):
"""Resizable preview pane that scales its pixmap with aspect ratio preserved."""
def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None):
super().__init__(title, parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None
layout = QVBoxLayout(self)
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setMinimumSize(360, 240)
self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.addWidget(self.image_label, 1)
if self._original:
self._update_scaled_pixmap()
else:
self.image_label.setText("No preview available")
self.image_label.setStyleSheet(
self.image_label.styleSheet() + "color: rgba(255,255,255,0.6); font-style: italic;"
)
def setPixmap(self, pixmap: QPixmap | None):
"""
Set the pixmap to display in the preview panel.
Args:
pixmap(QPixmap | None): The pixmap to display. If None or null, clears the preview.
"""
self._original = pixmap if (pixmap and not pixmap.isNull()) else None
if self._original:
self.image_label.setText("")
self._update_scaled_pixmap()
else:
self.image_label.setPixmap(QPixmap())
self.image_label.setText("No preview available")
def resizeEvent(self, event):
super().resizeEvent(event)
if self._original:
self._update_scaled_pixmap()
def _update_scaled_pixmap(self):
if not self._original:
return
size = self.image_label.size()
if size.width() <= 0 or size.height() <= 0:
return
scaled = self._original.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.image_label.setPixmap(scaled)
class RestoreProfileDialog(QDialog):
"""
Confirmation dialog that previews the current profile screenshot against the default baseline.
"""
def __init__(
self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
):
super().__init__(parent)
self.setWindowTitle("Restore Profile to Default")
self.setModal(True)
self.resize(880, 480)
layout = QVBoxLayout(self)
info_label = QLabel(
"Restoring will discard your custom layout and replace it with the default profile."
)
info_label.setWordWrap(True)
layout.addWidget(info_label)
preview_row = QHBoxLayout()
layout.addLayout(preview_row)
current_preview = PreviewPanel("Current", current_pixmap, self)
default_preview = PreviewPanel("Default", default_pixmap, self)
# Equal expansion left/right
preview_row.addWidget(current_preview, 1)
arrow_label = QLabel("\u2192")
arrow_label.setAlignment(Qt.AlignCenter)
arrow_label.setStyleSheet("font-size: 32px; padding: 0 16px;")
arrow_label.setMinimumWidth(40)
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
preview_row.addWidget(arrow_label)
preview_row.addWidget(default_preview, 1)
# Enforce equal stretch for both previews
preview_row.setStretch(0, 1)
preview_row.setStretch(1, 0)
preview_row.setStretch(2, 1)
warn_label = QLabel(
"This action cannot be undone. Do you want to restore the default layout now?"
)
warn_label.setWordWrap(True)
layout.addWidget(warn_label)
btn_row = QHBoxLayout()
btn_row.addStretch(1)
restore_btn = QPushButton("Restore")
restore_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
restore_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(restore_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Make the previews take most of the vertical space on resize
layout.setStretch(0, 0) # info label
layout.setStretch(1, 1) # preview row
layout.setStretch(2, 0) # warning label
layout.setStretch(3, 0) # buttons
@staticmethod
def confirm(
parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
) -> bool:
dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap)
return dialog.exec() == QDialog.Accepted

View File

@@ -0,0 +1,408 @@
from __future__ import annotations
from functools import partial
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QAbstractItemView,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
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,
get_profile_info,
is_quick_select,
list_profiles,
load_profile_screenshot,
set_quick_select,
)
logger = bec_logger.logger
class WorkSpaceManager(BECWidget, QWidget):
RPC = False
PLUGIN = False
COL_ACTIONS = 0
COL_NAME = 1
COL_AUTHOR = 2
HEADERS = ["Actions", "Profile", "Author"]
def __init__(
self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs
):
super().__init__(parent=parent, **kwargs)
self.target_widget = target_widget
self.profile_namespace = (
getattr(target_widget, "profile_namespace", None) if target_widget else None
)
self.accent_colors = get_accent_colors()
self._init_ui()
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
self.target_widget.profile_changed.connect(self.on_profile_changed)
if default_profile is not None:
self._select_by_name(default_profile)
self._show_profile_details(default_profile)
def _init_ui(self):
self.root_layout = QHBoxLayout(self)
self.splitter = QSplitter(Qt.Horizontal, self)
self.root_layout.addWidget(self.splitter)
# Init components
self._init_profile_table()
self._init_profile_details_tree()
self._init_screenshot_preview()
# Build two-column layout
left_col = QVBoxLayout()
left_col.addWidget(self.profile_table, 1)
left_col.addWidget(self.profile_details_tree, 0)
self.save_profile_button = QPushButton("Save current layout as new profile", self)
self.save_profile_button.clicked.connect(self.save_current_as_profile)
left_col.addWidget(self.save_profile_button)
self.save_profile_button.setEnabled(self.target_widget is not None)
# Wrap left widgets into a panel that participates in splitter sizing
left_panel = QWidget(self)
left_panel.setLayout(left_col)
left_panel.setMinimumWidth(220)
# Make the screenshot preview expand to fill remaining space
self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.right_box = QGroupBox("Profile Screenshot Preview", self)
right_col = QVBoxLayout(self.right_box)
right_col.addWidget(self.screenshot_label, 1)
self.splitter.addWidget(left_panel)
self.splitter.addWidget(self.right_box)
self.splitter.setStretchFactor(0, 0)
self.splitter.setStretchFactor(1, 1)
self.splitter.setSizes([350, 650])
def _init_profile_table(self):
self.profile_table = QTableWidget(self)
self.profile_table.setColumnCount(len(self.HEADERS))
self.profile_table.setHorizontalHeaderLabels(self.HEADERS)
self.profile_table.setAlternatingRowColors(True)
self.profile_table.verticalHeader().setVisible(False)
# Enforce row selection, single-select, and disable edits
self.profile_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.profile_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.profile_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
# Ensure the table expands to use vertical space in the left panel
self.profile_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
header = self.profile_table.horizontalHeader()
header.setStretchLastSection(False)
header.setDefaultAlignment(Qt.AlignCenter)
class _CenterDelegate(QStyledItemDelegate):
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.displayAlignment = Qt.AlignCenter
self.profile_table.setItemDelegate(_CenterDelegate(self.profile_table))
header.setSectionResizeMode(self.COL_ACTIONS, QHeaderView.ResizeToContents)
header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch)
header.setSectionResizeMode(self.COL_AUTHOR, QHeaderView.ResizeToContents)
self.render_table()
self.profile_table.itemSelectionChanged.connect(self._on_table_selection_changed)
self.profile_table.cellClicked.connect(self._on_cell_clicked)
def _init_profile_details_tree(self):
self.profile_details_tree = QTreeWidget(self)
self.profile_details_tree.setHeaderLabels(["Field", "Value"])
# Keep details compact so the table can expand
self.profile_details_tree.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
def _init_screenshot_preview(self):
self.screenshot_label = QLabel(self)
self.screenshot_label.setMinimumHeight(160)
self.screenshot_label.setAlignment(Qt.AlignCenter)
def render_table(self):
self.profile_table.setRowCount(0)
for profile in list_profiles(namespace=self.profile_namespace):
self._add_profile_row(profile)
def _add_profile_row(self, name: str):
row = self.profile_table.rowCount()
self.profile_table.insertRow(row)
actions_items = QWidget(self)
actions_items.profile_name = name
actions_items_layout = QHBoxLayout(actions_items)
actions_items_layout.setContentsMargins(0, 0, 0, 0)
info = get_profile_info(name, namespace=self.profile_namespace)
# Flags
is_active = (
self.target_widget is not None
and getattr(self.target_widget, "_current_profile_name", None) == name
)
quick = info.is_quick_select
is_read_only = info.is_read_only
# Play (green if active)
self._make_action_button(
actions_items,
"play_circle",
"Switch to this profile",
self.switch_profile,
filled=is_active,
color=(self.accent_colors.success if is_active else None),
)
# Quick-select (yellow if enabled)
self._make_action_button(
actions_items,
"star",
"Include in quick selection",
self.toggle_quick_select,
filled=quick,
color=(self.accent_colors.warning if quick else None),
)
# Delete (red, disabled when read-only)
delete_button = self._make_action_button(
actions_items,
"delete",
"Delete this profile",
self.delete_profile,
color=self.accent_colors.emergency,
)
if is_read_only:
delete_button.setEnabled(False)
delete_button.setToolTip("Bundled profiles are read-only and cannot be deleted.")
actions_items_layout.addStretch()
self.profile_table.setCellWidget(row, self.COL_ACTIONS, actions_items)
self.profile_table.setItem(row, self.COL_NAME, QTableWidgetItem(name))
self.profile_table.setItem(row, self.COL_AUTHOR, QTableWidgetItem(info.author))
def _make_action_button(
self,
parent: QWidget,
icon_name: str,
tooltip: str,
slot: callable,
*,
filled: bool = False,
color: str | None = None,
):
button = QToolButton(parent=parent)
button.setIcon(material_icon(icon_name, filled=filled, color=color))
button.setToolTip(tooltip)
button.clicked.connect(partial(slot, parent.profile_name))
parent.layout().addWidget(button)
return button
def _select_by_name(self, name: str) -> None:
for row in range(self.profile_table.rowCount()):
item = self.profile_table.item(row, self.COL_NAME)
if item and item.text() == name:
self.profile_table.selectRow(row)
break
def _current_selected_profile(self) -> str | None:
rows = self.profile_table.selectionModel().selectedRows()
if not rows:
return None
row = rows[0].row()
item = self.profile_table.item(row, self.COL_NAME)
return item.text() if item else None
def _show_profile_details(self, name: str) -> None:
info = get_profile_info(name, namespace=self.profile_namespace)
self.profile_details_tree.clear()
entries = [
("Name", info.name),
("Author", info.author or ""),
("Created", info.created or ""),
("Modified", info.modified or ""),
("Quick select", "Yes" if info.is_quick_select else "No"),
("Widgets", str(info.widget_count)),
("Size (KB)", str(info.size_kb)),
("User path", info.user_path or ""),
("Default path", info.default_path or ""),
]
for k, v in entries:
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
self.profile_details_tree.expandAll()
# Render screenshot preview from profile INI
pm = load_profile_screenshot(name, namespace=self.profile_namespace)
if pm is not None and not pm.isNull():
scaled = pm.scaled(
self.screenshot_label.width() or 800,
self.screenshot_label.height() or 450,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.screenshot_label.setPixmap(scaled)
else:
self.screenshot_label.setPixmap(QPixmap())
@SafeSlot()
def _on_table_selection_changed(self):
name = self._current_selected_profile()
if name:
self._show_profile_details(name)
@SafeSlot(int, int)
def _on_cell_clicked(self, row: int, column: int):
item = self.profile_table.item(row, self.COL_NAME)
if item:
self._show_profile_details(item.text())
##################################################
# Public Slots
##################################################
@SafeSlot(str)
def on_profile_changed(self, name: str):
"""Keep the manager in sync without forcing selection to the active profile."""
selected = self._current_selected_profile()
self.render_table()
if selected:
self._select_by_name(selected)
self._show_profile_details(selected)
@SafeSlot(str)
def switch_profile(self, profile_name: str):
self.target_widget.load_profile(profile_name)
try:
self.target_widget.toolbar.components.get_action(
"workspace_combo"
).widget.setCurrentText(profile_name)
except Exception as e:
logger.warning(f"Warning: Could not update workspace combo box. {e}")
self.render_table()
self._select_by_name(profile_name)
self._show_profile_details(profile_name)
@SafeSlot(str)
def toggle_quick_select(self, profile_name: str):
enabled = is_quick_select(profile_name, namespace=self.profile_namespace)
set_quick_select(profile_name, not enabled, namespace=self.profile_namespace)
self.render_table()
if self.target_widget is not None:
self.target_widget._refresh_workspace_list()
name = self._current_selected_profile()
if name:
self._show_profile_details(name)
@SafeSlot()
def save_current_as_profile(self):
if self.target_widget is None:
QMessageBox.information(
self,
"Save Profile",
"No workspace is associated with this manager. Attach a workspace to save profiles.",
)
return
self.target_widget.save_profile()
# AdvancedDockArea 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)
if current:
self._select_by_name(current)
self._show_profile_details(current)
@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
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:
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
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())
def resizeEvent(self, event):
super().resizeEvent(event)
name = self._current_selected_profile()
if not name:
return
pm = load_profile_screenshot(name, namespace=self.profile_namespace)
if pm is None or pm.isNull():
return
scaled = pm.scaled(
self.screenshot_label.width() or 800,
self.screenshot_label.height() or 450,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.screenshot_label.setPixmap(scaled)

View File

@@ -0,0 +1,198 @@
from __future__ import annotations
from typing import Callable
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont
from qtpy.QtWidgets import QComboBox, QSizePolicy
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
class ProfileComboBox(QComboBox):
"""Custom combobox that displays icons for read-only profiles."""
def __init__(self, parent=None):
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._quick_provider: Callable[[], list[str]] = list_quick_profiles
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider
def refresh_profiles(self, active_profile: str | None = None):
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
"""
current_text = active_profile or self.currentText()
self.blockSignals(True)
self.clear()
quick_profiles = self._quick_provider()
quick_set = set(quick_profiles)
items = list(quick_profiles)
if active_profile and active_profile not in quick_set:
items.insert(0, active_profile)
for profile in items:
self.addItem(profile)
idx = self.count() - 1
# Reset any custom styling
self.setItemData(idx, None, Qt.ItemDataRole.FontRole)
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
if active_profile and profile == active_profile:
tooltip = "Active workspace profile"
if profile not in quick_set:
font = QFont(self.font())
font.setItalic(True)
font.setBold(True)
self.setItemData(idx, font, Qt.ItemDataRole.FontRole)
self.setItemData(
idx, self.palette().highlight().color(), Qt.ItemDataRole.ForegroundRole
)
tooltip = "Active profile (not in quick select)"
self.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole)
self.setCurrentIndex(idx)
elif profile not in quick_set:
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)
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:
self.setToolTip("Active profile is not in quick select")
else:
self.setToolTip("")
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for AdvancedDockArea.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The workspace toolbar bundle.
"""
# Workspace combo
combo = ProfileComboBox(parent=components.toolbar)
combo.setVisible(enable_tools)
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
components.add_safe(
"save_workspace",
MaterialIconAction(
icon_name="save",
tooltip="Save Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
components.get_action("save_workspace").action.setVisible(enable_tools)
components.add_safe(
"reset_default_workspace",
MaterialIconAction(
icon_name="undo",
tooltip="Refresh Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
components.get_action("reset_default_workspace").action.setVisible(enable_tools)
components.add_safe(
"manage_workspaces",
MaterialIconAction(
icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar
),
)
components.get_action("manage_workspaces").action.setVisible(enable_tools)
bundle = ToolbarBundle("workspace", components)
bundle.add_action("workspace_combo")
bundle.add_action("save_workspace")
bundle.add_action("reset_default_workspace")
bundle.add_action("manage_workspaces")
return bundle
class WorkspaceConnection(BundleConnection):
"""
Connection class for workspace actions in AdvancedDockArea.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
super().__init__(parent=components.toolbar)
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'.")
self._connected = False
def connect(self):
self._connected = True
# 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)
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
self.target_widget.load_profile
)
reset_action = self.components.get_action("reset_default_workspace").action
if reset_action.isVisible():
reset_action.triggered.connect(self._reset_workspace_to_default)
manage_action = self.components.get_action("manage_workspaces").action
if manage_action.isVisible():
manage_action.triggered.connect(self.target_widget.show_workspace_manager)
def disconnect(self):
if not self._connected:
return
# 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)
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
self.target_widget.load_profile
)
reset_action = self.components.get_action("reset_default_workspace").action
if reset_action.isVisible():
reset_action.triggered.disconnect(self._reset_workspace_to_default)
manage_action = self.components.get_action("manage_workspaces").action
if manage_action.isVisible():
manage_action.triggered.disconnect(self.target_widget.show_workspace_manager)
self._connected = False
@SafeSlot()
def _reset_workspace_to_default(self):
"""
Refreshes the current workspace.
"""
self.target_widget.restore_user_profile_from_default()

View File

@@ -27,6 +27,7 @@ class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
PLUGIN = False
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"

View File

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

View File

@@ -1,22 +1,19 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
DOM_XML = """
<ui language='c++'>
<widget class='BECDockArea' name='dock_area'>
<widget class='BECDockArea' name='bec_dock_area'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -24,6 +21,8 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = BECDockArea(parent)
return t
@@ -31,13 +30,13 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Plots"
return "BEC Containers"
def icon(self):
return designer_material_icon(BECDockArea.ICON_NAME)
def includeFile(self):
return "dock_area"
return "bec_dock_area"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -52,7 +51,7 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "BECDockArea"
def toolTip(self):
return "BECDockArea"
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -389,6 +389,7 @@ class BECDock(BECWidget, Dock):
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
widget.deleteLater()
def delete_all(self):
"""

View File

@@ -71,6 +71,7 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock",
"attach_all",
"save_state",
"screenshot",
"restore_state",
]
@@ -267,11 +268,16 @@ class BECDockArea(BECWidget, QWidget):
"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):
@@ -333,6 +339,7 @@ class BECDockArea(BECWidget, QWidget):
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:
@@ -609,10 +616,10 @@ if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("auto")
apply_theme("dark")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")

View File

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

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin
from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
class CollapsibleSection(QWidget):
"""A widget that combines a header button with any content widget for collapsible sections
This widget contains a header button with a title and a content widget.
The content widget can be any QWidget. The header button can be expanded or collapsed.
The header also contains an "Add" button that is only visible when hovering over the section.
Signals:
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
onto another section for reordering.
Arguments are (source_title, target_title).
"""
section_reorder_requested = Signal(str, str) # (source_title, target_title)
def __init__(
self,
parent=None,
title="",
indentation=10,
show_add_button=False,
tooltip: str | None = None,
):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
self.setAcceptDrops(True)
self._expanded = True
# Setup layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
self.main_layout.setSpacing(0)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 4, 0)
header_layout.setSpacing(0)
# Create header button
self.header_button = QPushButton()
self.header_button.clicked.connect(self.toggle_expanded)
# Enable drag and drop for reordering
self.header_button.setAcceptDrops(True)
self.header_button.mousePressEvent = self._header_mouse_press_event
self.header_button.mouseMoveEvent = self._header_mouse_move_event
self.header_button.dragEnterEvent = self._header_drag_enter_event
self.header_button.dropEvent = self._header_drop_event
if tooltip:
self.header_button.setToolTip(tooltip)
self.drag_start_position = None
# Add header to layout
header_layout.addWidget(self.header_button)
header_layout.addStretch()
# Add button in header (icon-only)
self.header_add_button = QToolButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(28, 28)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.header_add_button.setAutoRaise(True)
self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
self._update_expanded_state()
def set_widget(self, widget):
"""Set the content widget for this collapsible section"""
# Remove existing content widget if any
if self.content_widget and self.content_widget.parent() == self:
self.main_layout.removeWidget(self.content_widget)
self.content_widget.close()
self.content_widget.deleteLater()
self.content_widget = widget
if self.content_widget:
self.main_layout.addWidget(self.content_widget)
self._update_expanded_state()
def _update_appearance(self):
"""Update the header button appearance based on expanded state"""
# Use material icons with consistent sizing to match tree items
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
self.header_button.setIcon(icon)
self.header_button.setText(self.title)
# Get theme colors
palette = get_theme_palette()
self.header_button.setStyleSheet(
"""
QPushButton {
font-weight: bold;
text-align: left;
margin: 0;
padding: 0px;
border: none;
background: transparent;
icon-size: 20px 20px;
}
"""
)
def toggle_expanded(self):
"""Toggle the expanded state and update size policy"""
self.expanded = not self.expanded
self._update_expanded_state()
def _update_expanded_state(self):
"""Update the expanded state based on current state"""
self._update_appearance()
if self.expanded:
if self.content_widget:
self.content_widget.show()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
else:
if self.content_widget:
self.content_widget.hide()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
@SafeProperty(bool)
def expanded(self) -> bool:
"""Get the expanded state"""
return self._expanded
@expanded.setter
def expanded(self, value: bool):
"""Set the expanded state programmatically"""
if not isinstance(value, bool):
raise ValueError("Expanded state must be a boolean")
if self._expanded == value:
return
self._expanded = value
self._update_appearance()
def connect_add_button(self, slot):
"""Connect a slot to the add button's clicked signal.
Args:
slot: The function to call when the add button is clicked.
"""
self.header_add_button.clicked.connect(slot)
def _header_mouse_press_event(self, event):
"""Handle mouse press on header for drag start"""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_position = event.pos()
QPushButton.mousePressEvent(self.header_button, event)
def _header_mouse_move_event(self, event):
"""Handle mouse move to start drag operation"""
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
# Check if we've moved far enough to start a drag
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
self._start_drag()
QPushButton.mouseMoveEvent(self.header_button, event)
def _start_drag(self):
"""Start the drag operation with a properly aligned widget pixmap"""
drag = QDrag(self.header_button)
mime_data = QMimeData()
mime_data.setText(f"section:{self.title}")
drag.setMimeData(mime_data)
# Grab a pixmap of the widget
widget_pixmap = self.header_button.grab()
drag.setPixmap(widget_pixmap)
# Set the hotspot to where the mouse was pressed on the widget
drag.setHotSpot(self.drag_start_position)
drag.exec_(Qt.MoveAction)
def _header_drag_enter_event(self, event):
"""Handle drag enter on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
event.acceptProposedAction()
else:
event.ignore()
def _header_drop_event(self, event):
"""Handle drop on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
source_title = event.mimeData().text().replace("section:", "")
if source_title != self.title:
# Emit signal to parent to handle reordering
self.section_reorder_requested.emit(source_title, self.title)
event.acceptProposedAction()
else:
event.ignore()

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
class Explorer(BECWidget, QWidget):
"""
A widget that combines multiple collapsible sections for an explorer-like interface.
Each section can be expanded or collapsed, and sections can be reordered. The explorer
can contain also sub-explorers for nested structures.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Main layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# Splitter for sections
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.main_layout.addWidget(self.splitter)
# Spacer for when all sections are collapsed
self.expander = QSpacerItem(0, 0)
self.main_layout.addItem(self.expander)
# Registry of sections
self.sections: list[CollapsibleSection] = []
# Setup splitter styling
self._setup_splitter_styling()
def add_section(self, section: CollapsibleSection) -> None:
"""
Add a collapsible section to the explorer
Args:
section (CollapsibleSection): The section to add
"""
if not isinstance(section, CollapsibleSection):
raise TypeError("section must be an instance of CollapsibleSection")
if section in self.sections:
return
self.sections.append(section)
self.splitter.addWidget(section)
# Connect the section's toggle to update spacer
section.header_button.clicked.connect(self._update_spacer)
# Connect section reordering if supported
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.connect(self._handle_section_reorder)
self._update_spacer()
def remove_section(self, section: CollapsibleSection) -> None:
"""
Remove a collapsible section from the explorer
Args:
section (CollapsibleSection): The section to remove
"""
if section not in self.sections:
return
self.sections.remove(section)
section.deleteLater()
section.close()
# Disconnect signals
try:
section.header_button.clicked.disconnect(self._update_spacer)
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.disconnect(self._handle_section_reorder)
except RuntimeError:
# Signals already disconnected
pass
self._update_spacer()
def get_section(self, title: str) -> CollapsibleSection | None:
"""Get a section by its title"""
for section in self.sections:
if section.title == title:
return section
return None
def _setup_splitter_styling(self) -> None:
"""Setup the splitter styling with theme colors"""
palette = get_theme_palette()
separator_color = palette.mid().color()
self.splitter.setStyleSheet(
f"""
QSplitter::handle {{
height: 0.1px;
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
}}
"""
)
def _update_spacer(self) -> None:
"""Update the spacer size based on section states"""
any_expanded = any(section.expanded for section in self.sections)
if any_expanded:
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else:
self.expander.changeSize(
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
)
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
"""Handle reordering of sections"""
if source_title == target_title:
return
source_section = self.get_section(source_title)
target_section = self.get_section(target_title)
if not source_section or not target_section:
return
# Get current indices
source_index = self.splitter.indexOf(source_section)
target_index = self.splitter.indexOf(target_section)
if source_index == -1 or target_index == -1:
return
# Insert at target position
self.splitter.insertWidget(target_index, source_section)
# Update sections
self.sections.remove(source_section)
self.sections.insert(target_index, source_section)
if __name__ == "__main__":
import os
from qtpy.QtWidgets import QApplication, QLabel
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
app = QApplication([])
explorer = Explorer()
section = CollapsibleSection(title="SCRIPTS", indentation=0)
script_explorer = Explorer()
script_widget = ScriptTreeWidget()
local_scripts_section = CollapsibleSection(title="Local")
local_scripts_section.set_widget(script_widget)
script_widget.set_directory(os.path.abspath("./"))
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
explorer.add_section(section)
shared_script_section = CollapsibleSection(title="Shared")
shared_script_widget = ScriptTreeWidget()
shared_script_widget.set_directory(os.path.abspath("./"))
shared_script_section.set_widget(shared_script_widget)
script_explorer.add_section(shared_script_section)
macros_section = CollapsibleSection(title="MACROS", indentation=0)
macros_section.set_widget(QLabel("Macros will be implemented later"))
explorer.add_section(macros_section)
explorer.show()
app.exec()

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from typing import Any
from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView
from bec_widgets.utils.colors import get_theme_palette
class ExplorerDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover for the explorer"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.button_rects: list[QRect] = []
self.current_macro_info = {}
self.target_model = QSortFilterProxyModel
def paint(self, painter, option, index):
"""Paint the item with action buttons on hover"""
# Paint the default item
super().paint(painter, option, index)
# Early return if not hovering over this item
if index != self.hovered_index:
return
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, self.target_model):
return
actions = self.get_actions_for_current_item(proxy_model, index)
if actions:
self._draw_action_buttons(painter, option, actions)
def _draw_action_buttons(self, painter, option, actions: list[Any]):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
spacing = 2
# Calculate total width needed for all buttons
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
# Clear previous button rects and create new ones
self.button_rects.clear()
# Calculate starting position (right side of the item)
start_x = option.rect.right() - total_width - margin
current_x = start_x
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Get theme colors for better integration
palette = get_theme_palette()
button_bg = palette.button().color()
button_bg.setAlpha(150) # Semi-transparent
for action in actions:
if not action.isVisible():
continue
# Calculate button position
button_rect = QRect(
current_x,
option.rect.top() + (option.rect.height() - button_size) // 2,
button_size,
button_size,
)
self.button_rects.append(button_rect)
# Draw button background
painter.setBrush(button_bg)
painter.setPen(palette.mid().color())
painter.drawRoundedRect(button_rect, 3, 3)
# Draw action icon
icon = action.icon()
if not icon.isNull():
icon_rect = button_rect.adjusted(2, 2, -2, -2)
icon.paint(painter, icon_rect)
# Move to next button position
current_x += button_size + spacing
painter.restore()
def get_actions_for_current_item(self, model, index) -> list[QAction] | None:
"""Get actions for the current item based on its type"""
return None
def editorEvent(self, event, model, option, index):
"""Handle mouse events for action buttons"""
# Early return if not a left click
if not (
event.type() == event.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton
):
return super().editorEvent(event, model, option, index)
actions = self.get_actions_for_current_item(model, index)
if not actions:
return super().editorEvent(event, model, option, index)
# Check which button was clicked
visible_actions = [action for action in actions if action.isVisible()]
for i, button_rect in enumerate(self.button_rects):
if button_rect.contains(event.pos()) and i < len(visible_actions):
# Trigger the action
visible_actions[i].trigger()
return True
return super().editorEvent(event, model, option, index)
def set_hovered_index(self, index):
"""Set the currently hovered index"""
self.hovered_index = index

View File

@@ -0,0 +1,382 @@
import ast
import os
from pathlib import Path
from typing import Any
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRect, Qt, Signal
from qtpy.QtGui import QStandardItem, QStandardItemModel
from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate
logger = bec_logger.logger
class MacroItemDelegate(ExplorerDelegate):
"""Custom delegate to show action buttons on hover for macro functions"""
def __init__(self, parent=None):
super().__init__(parent)
self.macro_actions: list[Any] = []
self.button_rects: list[QRect] = []
self.current_macro_info = {}
self.target_model = QStandardItemModel
def add_macro_action(self, action: Any) -> None:
"""Add an action for macro functions"""
self.macro_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
self.macro_actions.clear()
def get_actions_for_current_item(self, model, index) -> list[QAction] | None:
# Only show actions for macro functions (not directories)
item = index.model().itemFromIndex(index)
if not item or not item.data(Qt.ItemDataRole.UserRole):
return
macro_info = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(macro_info, dict) or "function_name" not in macro_info:
return
self.current_macro_info = macro_info
return self.macro_actions
class MacroTreeWidget(QWidget):
"""A tree widget that displays macro functions from Python files"""
macro_selected = Signal(str, str) # Function name, file path
macro_open_requested = Signal(str, str) # Function name, file path
def __init__(self, parent=None):
super().__init__(parent)
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
# Disable editing to prevent renaming on double-click
self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create model for macro functions
self.model = QStandardItemModel()
self.tree.setModel(self.model)
# Create and set custom delegate
self.delegate = MacroItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for macros
action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self)
action.action.triggered.connect(self._on_macro_open_requested)
self.delegate.add_macro_action(action.action)
# Apply BEC styling
self._apply_styling()
# Macro specific properties
self.directory = None
# Connect signals
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
# Install event filter for hover tracking
self.tree.viewport().installEventFilter(self)
# Add to layout
layout.addWidget(self.tree)
def _apply_styling(self):
"""Apply styling to the tree widget"""
# Get theme colors for subtle tree lines
palette = get_theme_palette()
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# Standard editable styling
opacity_modifier = ""
cursor_style = ""
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
{opacity_modifier}
{cursor_style}
}}
QTreeView::branch {{
border-image: none;
background: transparent;
}}
QTreeView::item {{
border: none;
padding: 0px;
margin: 0px;
}}
QTreeView::item:hover {{
background: palette(midlight);
border: none;
padding: 0px;
margin: 0px;
text-decoration: none;
}}
QTreeView::item:selected {{
background: palette(highlight);
color: palette(highlighted-text);
}}
QTreeView::item:selected:hover {{
background: palette(highlight);
}}
"""
self.tree.setStyleSheet(tree_style)
def eventFilter(self, obj, event):
"""Handle mouse move events for hover tracking"""
# Early return if not the tree viewport
if obj != self.tree.viewport():
return super().eventFilter(obj, event)
if event.type() == event.Type.MouseMove:
index = self.tree.indexAt(event.pos())
if index.isValid():
self.delegate.set_hovered_index(index)
else:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
if event.type() == event.Type.Leave:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
return super().eventFilter(obj, event)
def set_directory(self, directory):
"""Set the macros directory and scan for macro functions"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
self._scan_macro_functions()
def _create_file_item(self, py_file: Path) -> QStandardItem | None:
"""Create a file item with its functions
Args:
py_file: Path to the Python file
Returns:
QStandardItem representing the file, or None if no functions found
"""
# Skip files starting with underscore
if py_file.name.startswith("_"):
return None
try:
functions = self._extract_functions_from_file(py_file)
if not functions:
return None
# Create a file node
file_item = QStandardItem(py_file.stem)
file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole)
# Add function nodes
for func_name, func_info in functions.items():
func_item = QStandardItem(func_name)
func_data = {
"function_name": func_name,
"file_path": str(py_file),
"line_number": func_info.get("line_number", 1),
"type": "function",
}
func_item.setData(func_data, Qt.ItemDataRole.UserRole)
file_item.appendRow(func_item)
return file_item
except Exception as e:
logger.warning(f"Failed to parse {py_file}: {e}")
return None
def _scan_macro_functions(self):
"""Scan the directory for Python files and extract macro functions"""
self.model.clear()
self.model.setHorizontalHeaderLabels(["Macros"])
if not self.directory or not os.path.exists(self.directory):
return
# Get all Python files in the directory
python_files = list(Path(self.directory).glob("*.py"))
for py_file in python_files:
file_item = self._create_file_item(py_file)
if file_item:
self.model.appendRow(file_item)
self.tree.expandAll()
def _extract_functions_from_file(self, file_path: Path) -> dict:
"""Extract function definitions from a Python file"""
functions = {}
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Parse the AST
tree = ast.parse(content)
# Only get top-level function definitions
for node in tree.body:
if isinstance(node, ast.FunctionDef):
functions[node.name] = {
"line_number": node.lineno,
"docstring": ast.get_docstring(node) or "",
}
except Exception as e:
logger.warning(f"Failed to parse {file_path}: {e}")
return functions
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
item = self.model.itemFromIndex(index)
if not item:
return
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
if data.get("type") == "function":
function_name = data.get("function_name")
file_path = data.get("file_path")
if function_name and file_path:
logger.info(f"Macro function selected: {function_name} in {file_path}")
self.macro_selected.emit(function_name, file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
item = self.model.itemFromIndex(index)
if not item:
return
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
if data.get("type") == "function":
function_name = data.get("function_name")
file_path = data.get("file_path")
if function_name and file_path:
logger.info(
f"Macro open requested via double-click: {function_name} in {file_path}"
)
self.macro_open_requested.emit(function_name, file_path)
def _on_macro_open_requested(self):
"""Handle macro open action triggered"""
logger.info("Macro open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
macro_info = self.delegate.current_macro_info
if not macro_info or macro_info.get("type") != "function":
return
function_name = macro_info.get("function_name")
file_path = macro_info.get("file_path")
if function_name and file_path:
self.macro_open_requested.emit(function_name, file_path)
def add_macro_action(self, action: Any) -> None:
"""Add an action for macro items"""
self.delegate.add_macro_action(action)
def clear_actions(self) -> None:
"""Remove all actions from items"""
self.delegate.clear_actions()
def refresh(self):
"""Refresh the tree view"""
if self.directory is None:
return
self._scan_macro_functions()
def refresh_file_item(self, file_path: str):
"""Refresh a single file item by re-scanning its functions
Args:
file_path: Path to the Python file to refresh
"""
if not file_path or not os.path.exists(file_path):
logger.warning(f"Cannot refresh file item: {file_path} does not exist")
return
py_file = Path(file_path)
# Find existing file item in the model
existing_item = None
existing_row = -1
for row in range(self.model.rowCount()):
item = self.model.item(row)
if not item or not item.data(Qt.ItemDataRole.UserRole):
continue
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file):
existing_item = item
existing_row = row
break
# Store expansion state if item exists
was_expanded = existing_item and self.tree.isExpanded(existing_item.index())
# Remove existing item if found
if existing_item and existing_row >= 0:
self.model.removeRow(existing_row)
# Create new item using the helper method
new_item = self._create_file_item(py_file)
if new_item:
# Insert at the same position or append if it was a new file
insert_row = existing_row if existing_row >= 0 else self.model.rowCount()
self.model.insertRow(insert_row, new_item)
# Restore expansion state
if was_expanded:
self.tree.expand(new_item.index())
else:
self.tree.expand(new_item.index())
def expand_all(self):
"""Expand all items in the tree"""
self.tree.expandAll()
def collapse_all(self):
"""Collapse all items in the tree"""
self.tree.collapseAll()

View File

@@ -0,0 +1,282 @@
import os
from pathlib import Path
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal
from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate
logger = bec_logger.logger
class FileItemDelegate(ExplorerDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, tree_widget):
super().__init__(tree_widget)
self.file_actions = []
self.dir_actions = []
def add_file_action(self, action) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
self.file_actions.clear()
self.dir_actions.clear()
def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None:
"""Get actions for the current item based on its type"""
if not isinstance(model, QSortFilterProxyModel):
return None
source_index = model.mapToSource(index)
source_model = model.sourceModel()
if not isinstance(source_model, QFileSystemModel):
return None
is_dir = source_model.isDir(source_index)
return self.dir_actions if is_dir else self.file_actions
class ScriptTreeWidget(QWidget):
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
file_selected = Signal(str) # Script file path selected
file_open_requested = Signal(str) # File open button clicked
file_renamed = Signal(str, str) # Old path, new path
def __init__(self, parent=None):
super().__init__(parent)
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)
# Create and set custom delegate
self.delegate = FileItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for files
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
action.action.triggered.connect(self._on_file_open_requested)
self.delegate.add_file_action(action.action)
# Remove unnecessary columns
self.tree.setColumnHidden(1, True) # Hide size column
self.tree.setColumnHidden(2, True) # Hide type column
self.tree.setColumnHidden(3, True) # Hide date modified column
# Apply BEC styling
self._apply_styling()
# Script specific properties
self.directory = None
# Connect signals
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
# Install event filter for hover tracking
self.tree.viewport().installEventFilter(self)
# Add to layout
layout.addWidget(self.tree)
def _apply_styling(self):
"""Apply styling to the tree widget"""
# Get theme colors for subtle tree lines
palette = get_theme_palette()
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# Standard editable styling
opacity_modifier = ""
cursor_style = ""
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
{opacity_modifier}
{cursor_style}
}}
QTreeView::branch {{
border-image: none;
background: transparent;
}}
QTreeView::item {{
border: none;
padding: 0px;
margin: 0px;
}}
QTreeView::item:hover {{
background: palette(midlight);
border: none;
padding: 0px;
margin: 0px;
text-decoration: none;
}}
QTreeView::item:selected {{
background: palette(highlight);
color: palette(highlighted-text);
}}
QTreeView::item:selected:hover {{
background: palette(highlight);
}}
"""
self.tree.setStyleSheet(tree_style)
def eventFilter(self, obj, event):
"""Handle mouse move events for hover tracking"""
# Early return if not the tree viewport
if obj != self.tree.viewport():
return super().eventFilter(obj, event)
if event.type() == event.Type.MouseMove:
index = self.tree.indexAt(event.pos())
if index.isValid():
self.delegate.set_hovered_index(index)
else:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
if event.type() == event.Type.Leave:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
return super().eventFilter(obj, event)
def set_directory(self, directory: str) -> None:
"""Set the scripts directory"""
# Early return if directory doesn't exist
if not directory or not isinstance(directory, str) or not os.path.exists(directory):
return
self.directory = directory
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
self.tree.expandAll()
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
path_obj = Path(file_path)
# Only emit signal for Python files
if path_obj.suffix.lower() == ".py":
logger.info(f"Script selected: {file_path}")
self.file_selected.emit(file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
# Emit signal to open the file
logger.info(f"File open requested via double-click: {file_path}")
self.file_open_requested.emit(file_path)
def _on_file_open_requested(self):
"""Handle file open action triggered"""
logger.info("File open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
self.file_open_requested.emit(file_path)
def add_file_action(self, action) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_action(action)
def clear_actions(self) -> None:
"""Remove all actions from items"""
self.delegate.clear_actions()
def refresh(self):
"""Refresh the tree view"""
if self.directory is None:
return
self.model.setRootPath("") # Reset
root_index = self.model.setRootPath(self.directory)
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
def expand_all(self):
"""Expand all items in the tree"""
self.tree.expandAll()
def collapse_all(self):
"""Collapse all items in the tree"""
self.tree.collapseAll()

View File

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

View File

@@ -0,0 +1,73 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import QSize
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
DOM_XML = """
<ui language='c++'>
<widget class='BECMainWindow' name='bec_main_window'>
</widget>
</ui>
"""
class BECMainWindowPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
# We want to initialize BECMainWindow upon starting designer
t = BECMainWindow(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Containers"
def icon(self):
return designer_material_icon(BECMainWindow.ICON_NAME)
def includeFile(self):
return "bec_main_window"
def initialize(self, form_editor):
import os
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True)
app = QApplication.instance()
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
self._form_editor = form_editor
def isContainer(self):
return True
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECMainWindow"
def toolTip(self):
return "BECMainWindow"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
@@ -19,9 +20,8 @@ from qtpy.QtWidgets import (
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker,
@@ -34,23 +34,18 @@ from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanP
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)
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
PLUGIN = False
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
def __init__(
self,
parent=None,
gui_id: str = None,
client=None,
window_title: str = "BEC",
*args,
**kwargs,
):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
def __init__(self, parent=None, window_title: str = "BEC", **kwargs):
super().__init__(parent=parent, **kwargs)
self.app = QApplication.instance()
self.status_bar = self.statusBar()
@@ -354,7 +349,7 @@ class BECMainWindow(BECWidget, QMainWindow):
########################################
# Theme menu
theme_menu = menu_bar.addMenu("Theme")
theme_menu = menu_bar.addMenu("View")
theme_group = QActionGroup(self)
light_theme_action = QAction("Light Theme", self, checkable=True)
@@ -371,18 +366,19 @@ class BECMainWindow(BECWidget, QMainWindow):
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
# Set the default theme
theme = self.app.theme.theme
if theme == "light":
light_theme_action.setChecked(True)
elif theme == "dark":
dark_theme_action.setChecked(True)
if hasattr(self.app, "theme") and self.app.theme:
theme_name = self.app.theme.theme.lower()
if "light" in theme_name:
light_theme_action.setChecked(True)
elif "dark" in theme_name:
dark_theme_action.setChecked(True)
########################################
# Help menu
help_menu = menu_bar.addMenu("Help")
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
help_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)
bec_docs = QAction("BEC Docs", self)
bec_docs.setIcon(help_icon)
@@ -445,7 +441,7 @@ class BECMainWindow(BECWidget, QMainWindow):
Args:
theme(str): Either "light" or "dark".
"""
set_theme(theme) # emits theme_updated and applies palette globally
apply_theme(theme) # emits theme_updated and applies palette globally
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
@@ -453,21 +449,6 @@ class BECMainWindow(BECWidget, QMainWindow):
return super().event(event)
def cleanup(self):
central_widget = self.centralWidget()
if central_widget is not None:
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
children = self.findChildren(BECWidget)
for child in children:
ancestor = WidgetHierarchy._get_becwidget_ancestor(child)
if ancestor is self:
child.cleanup()
child.close()
child.deleteLater()
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
@@ -491,15 +472,16 @@ class BECMainWindow(BECWidget, QMainWindow):
super().cleanup()
class UILaunchWindow(BECMainWindow):
RPC = True
class BECMainWindowNoRPC(BECMainWindow):
RPC = False
PLUGIN = False
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = UILaunchWindow()
main_window = BECMainWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.containers.main_window.bec_main_window_plugin import (
BECMainWindowPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMainWindowPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1 @@
from PySide6QtAds import *

View File

@@ -0,0 +1,989 @@
from __future__ import annotations
import collections
import enum
import typing
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Signal
from bec_widgets.widgets.containers.qt_ads import ads
from bec_widgets.widgets.containers.qt_ads.ads import *
# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called
class CAutoHideDockContainer(QtWidgets.QFrame):
def __init__(
self,
DockWidget: typing.Optional["CDockWidget"],
area: SideBarLocation,
parent: typing.Optional["CDockContainerWidget"],
) -> None: ...
def moveToNewSideBarLocation(self, a0: SideBarLocation) -> None: ...
def orientation(self) -> QtCore.Qt.Orientation: ...
def resetToInitialDockWidgetSize(self) -> None: ...
def setSize(self, Size: int) -> None: ...
def toggleCollapseState(self) -> None: ...
def collapseView(self, Enable: bool) -> None: ...
def toggleView(self, Enable: bool) -> None: ...
def cleanupAndDelete(self) -> None: ...
def moveContentsToParent(self) -> None: ...
def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ...
def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ...
def setSideBarLocation(self, SideBarLocation: SideBarLocation) -> None: ...
def sideBarLocation(self) -> SideBarLocation: ...
def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def tabIndex(self) -> int: ...
def dockWidget(self) -> typing.Optional["CDockWidget"]: ...
def autoHideTab(self) -> typing.Optional["CAutoHideTab"]: ...
def autoHideSideBar(self) -> typing.Optional["CAutoHideSideBar"]: ...
def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ...
def updateSize(self) -> None: ...
def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ...
def leaveEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ...
def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ...
def eventFilter(
self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent]
) -> bool: ...
class CAutoHideSideBar(QtWidgets.QScrollArea):
def __init__(
self, parent: typing.Optional["CDockContainerWidget"], area: "SideBarLocation"
) -> None: ...
def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ...
def setSpacing(self, Spacing: int) -> None: ...
def spacing(self) -> int: ...
def sizeHint(self) -> QtCore.QSize: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def sideBarLocation(self) -> "SideBarLocation": ...
def hasVisibleTabs(self) -> bool: ...
def visibleTabCount(self) -> int: ...
def count(self) -> int: ...
def indexOfTab(self, Tab: "CAutoHideTab") -> int: ...
def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ...
def tabAt(self, Pos: QtCore.QPoint) -> int: ...
def tab(self, index: int) -> typing.Optional["CAutoHideTab"]: ...
def orientation(self) -> QtCore.Qt.Orientation: ...
def addAutoHideWidget(
self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"], Index: int
) -> None: ...
def removeAutoHideWidget(
self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"]
) -> None: ...
def insertDockWidget(
self, Index: int, DockWidget: typing.Optional["CDockWidget"]
) -> typing.Optional["CAutoHideDockContainer"]: ...
def removeTab(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ...
def insertTab(self, Index: int, SideTab: typing.Optional["CAutoHideTab"]) -> None: ...
def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ...
def eventFilter(
self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent]
) -> bool: ...
class CPushButton(QtWidgets.QPushButton):
class Orientation(enum.Enum):
Horizontal = ...
VerticalTopToBottom = ...
VerticalBottomToTop = ...
def __init__(self) -> None: ...
def setButtonOrientation(self, orientation: "CPushButton.Orientation") -> None: ...
def buttonOrientation(self) -> "CPushButton.Orientation": ...
def sizeHint(self) -> QtCore.QSize: ...
class CAutoHideTab(CPushButton):
def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ...
def requestCloseDockWidget(self) -> None: ...
def unpinDockWidget(self) -> None: ...
def setDockWidgetFloating(self) -> None: ...
def tabIndex(self) -> int: ...
def sideBar(self) -> typing.Optional["CAutoHideSideBar"]: ...
def iconOnly(self) -> bool: ...
def setDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def dockWidget(self) -> typing.Optional["CDockWidget"]: ...
def isActiveTab(self) -> bool: ...
def orientation(self) -> QtCore.Qt.Orientation: ...
def setOrientation(self, Orientation: QtCore.Qt.Orientation) -> None: ...
def sideBarLocation(self) -> "SideBarLocation": ...
def updateStyle(self) -> None: ...
def dragLeaveEvent(self, ev: typing.Optional[QtGui.QDragLeaveEvent]) -> None: ...
def dragEnterEvent(self, ev: typing.Optional[QtGui.QDragEnterEvent]) -> None: ...
def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ...
def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ...
def removeFromSideBar(self) -> None: ...
def setSideBar(self, SideTabBar: typing.Optional["CAutoHideSideBar"]) -> None: ...
class CDockWidget(QtWidgets.QFrame):
class eToggleViewActionMode(enum.Enum):
ActionModeToggle = ...
ActionModeShow = ...
class eMinimumSizeHintMode(enum.Enum):
MinimumSizeHintFromDockWidget = ...
MinimumSizeHintFromContent = ...
MinimumSizeHintFromDockWidgetMinimumSize = ...
MinimumSizeHintFromContentMinimumSize = ...
class eInsertMode(enum.Enum):
AutoScrollArea = ...
ForceScrollArea = ...
ForceNoScrollArea = ...
class eToolBarStyleSource(enum.Enum):
ToolBarStyleFromDockManager = ...
ToolBarStyleFromDockWidget = ...
class eState(enum.Enum):
StateHidden = ...
StateDocked = ...
StateFloating = ...
class DockWidgetFeature(enum.Enum):
DockWidgetClosable = ...
DockWidgetMovable = ...
DockWidgetFloatable = ...
DockWidgetDeleteOnClose = ...
CustomCloseHandling = ...
DockWidgetFocusable = ...
DockWidgetForceCloseWithArea = ...
NoTab = ...
DeleteContentOnClose = ...
DockWidgetPinnable = ...
DefaultDockWidgetFeatures = ...
AllDockWidgetFeatures = ...
DockWidgetAlwaysCloseAndDelete = ...
GloballyLockableFeatures = ...
NoDockWidgetFeatures = ...
@typing.overload
def __init__(
self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ...
) -> None: ...
@typing.overload
def __init__(
self,
manager: typing.Optional["CDockManager"],
title: typing.Optional[str],
parent: typing.Optional[QtWidgets.QWidget] = ...,
) -> None: ...
featuresChanged: typing.ClassVar[Signal]
visibilityChanged: typing.ClassVar[Signal]
closeRequested: typing.ClassVar[Signal]
topLevelChanged: typing.ClassVar[Signal]
titleChanged: typing.ClassVar[Signal]
closed: typing.ClassVar[Signal]
viewToggled: typing.ClassVar[Signal]
def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ...
def setAutoHide(
self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ...
) -> None: ...
def showNormal(self) -> None: ...
def showFullScreen(self) -> None: ...
def requestCloseDockWidget(self) -> None: ...
def closeDockWidget(self) -> None: ...
def deleteDockWidget(self) -> None: ...
def setFloating(self) -> None: ...
def raise_(self) -> None: ...
def setAsCurrentTab(self) -> None: ...
def toggleView(self, Open: bool = ...) -> None: ...
def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ...
def isCurrentTab(self) -> bool: ...
def isTabbed(self) -> bool: ...
def isFullScreen(self) -> bool: ...
def setTabToolTip(self, text: typing.Optional[str]) -> None: ...
def titleBarActions(self) -> list[QtGui.QAction]: ...
def setTitleBarActions(self, actions: collections.abc.Iterable[QtGui.QAction]) -> None: ...
def toolBarIconSize(self, State: "CDockWidget.eState") -> QtCore.QSize: ...
def setToolBarIconSize(self, IconSize: QtCore.QSize, State: "CDockWidget.eState") -> None: ...
def toolBarStyle(self, State: "CDockWidget.eState") -> QtCore.Qt.ToolButtonStyle: ...
def setToolBarStyle(
self, Style: QtCore.Qt.ToolButtonStyle, State: "CDockWidget.eState"
) -> None: ...
def toolBarStyleSource(self) -> "CDockWidget.eToolBarStyleSource": ...
def setToolBarStyleSource(self, Source: "CDockWidget.eToolBarStyleSource") -> None: ...
def setToolBar(self, ToolBar: typing.Optional[QtWidgets.QToolBar]) -> None: ...
def createDefaultToolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ...
def toolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ...
def icon(self) -> QtGui.QIcon: ...
def setIcon(self, Icon: QtGui.QIcon) -> None: ...
def isCentralWidget(self) -> bool: ...
def minimumSizeHintMode(self) -> "CDockWidget.eMinimumSizeHintMode": ...
def setMinimumSizeHintMode(self, Mode: "CDockWidget.eMinimumSizeHintMode") -> None: ...
def setToggleViewActionMode(self, Mode: "CDockWidget.eToggleViewActionMode") -> None: ...
def setToggleViewAction(self, action: typing.Optional[QtGui.QAction]) -> None: ...
def toggleViewAction(self) -> typing.Optional[QtGui.QAction]: ...
def isClosed(self) -> bool: ...
def isInFloatingContainer(self) -> bool: ...
def isFloating(self) -> bool: ...
def autoHideLocation(self) -> "SideBarLocation": ...
def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ...
def isAutoHide(self) -> bool: ...
def setSideTabWidget(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ...
def sideTabWidget(self) -> typing.Optional["CAutoHideTab"]: ...
def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ...
def floatingDockContainer(self) -> typing.Optional["CFloatingDockContainer"]: ...
def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ...
def dockManager(self) -> typing.Optional["CDockManager"]: ...
def features(self) -> "CDockWidget.DockWidgetFeature": ...
def setFeature(self, flag: "CDockWidget.DockWidgetFeature", on: bool) -> None: ...
def setFeatures(self, features: "CDockWidget.DockWidgetFeature") -> None: ...
def tabWidget(self) -> typing.Optional["CDockWidgetTab"]: ...
def widget(self) -> typing.Optional[QtWidgets.QWidget]: ...
def takeWidget(self) -> typing.Optional[QtWidgets.QWidget]: ...
def setWidget(
self,
widget: typing.Optional[QtWidgets.QWidget],
InsertMode: "CDockWidget.eInsertMode" = ...,
) -> None: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def closeDockWidgetInternal(self, ForceClose: bool = ...) -> bool: ...
def toggleViewInternal(self, Open: bool) -> None: ...
def setClosedState(self, Closed: bool) -> None: ...
def emitTopLevelChanged(self, Floating: bool) -> None: ...
@staticmethod
def emitTopLevelEventForWidget(
TopLevelDockWidget: typing.Optional["CDockWidget"], Floating: bool
) -> None: ...
def flagAsUnassigned(self) -> None: ...
def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ...
def setToggleViewActionChecked(self, Checked: bool) -> None: ...
def setDockArea(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ...
def setDockManager(self, DockManager: typing.Optional["CDockManager"]) -> None: ...
class CDockAreaTabBar(QtWidgets.QScrollArea):
def __init__(self, parent: typing.Optional["CDockAreaWidget"]) -> None: ...
elidedChanged: typing.ClassVar[Signal]
tabInserted: typing.ClassVar[Signal]
removingTab: typing.ClassVar[Signal]
tabMoved: typing.ClassVar[Signal]
tabOpened: typing.ClassVar[Signal]
tabClosed: typing.ClassVar[Signal]
tabCloseRequested: typing.ClassVar[Signal]
tabBarClicked: typing.ClassVar[Signal]
currentChanged: typing.ClassVar[Signal]
currentChanging: typing.ClassVar[Signal]
def closeTab(self, Index: int) -> None: ...
def setCurrentIndex(self, Index: int) -> None: ...
def areTabsOverflowing(self) -> bool: ...
def sizeHint(self) -> QtCore.QSize: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def isTabOpen(self, Index: int) -> bool: ...
def eventFilter(
self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent]
) -> bool: ...
def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ...
def tabAt(self, Pos: QtCore.QPoint) -> int: ...
def tab(self, Index: int) -> typing.Optional["CDockWidgetTab"]: ...
def currentTab(self) -> typing.Optional["CDockWidgetTab"]: ...
def currentIndex(self) -> int: ...
def count(self) -> int: ...
def removeTab(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ...
def insertTab(self, Index: int, Tab: typing.Optional["CDockWidgetTab"]) -> None: ...
def wheelEvent(self, Event: typing.Optional[QtGui.QWheelEvent]) -> None: ...
class CSpacerWidget(QtWidgets.QWidget):
def __init__(self, Parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def sizeHint(self) -> QtCore.QSize: ...
class CTitleBarButton(QtWidgets.QToolButton):
def __init__(
self,
ShowInTitleBar: bool,
HideWhenDisabled: bool,
ButtonId: "TitleBarButton",
parent: typing.Optional[QtWidgets.QWidget] = ...,
) -> None: ...
def event(self, ev: typing.Optional[QtCore.QEvent]) -> bool: ...
def isInAutoHideArea(self) -> bool: ...
def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ...
def buttonId(self) -> "TitleBarButton": ...
def setShowInTitleBar(self, a0: bool) -> None: ...
def setVisible(self, a0: bool) -> None: ...
class CDockAreaTitleBar(QtWidgets.QFrame):
def __init__(self, parent: typing.Optional[CDockAreaWidget]) -> None: ...
tabBarClicked: typing.ClassVar[Signal]
def buildContextMenu(
self, menu: typing.Optional[QtWidgets.QMenu] = ...
) -> typing.Optional[QtWidgets.QMenu]: ...
def isAutoHide(self) -> bool: ...
def showAutoHideControls(self, Show: bool) -> None: ...
def setAreaFloating(self) -> None: ...
def titleBarButtonToolTip(self, Button: "TitleBarButton") -> str: ...
def indexOf(self, widget: typing.Optional[QtWidgets.QWidget]) -> int: ...
def insertWidget(self, index: int, widget: typing.Optional[QtWidgets.QWidget]) -> None: ...
def setVisible(self, Visible: bool) -> None: ...
def updateDockWidgetActionsButtons(self) -> None: ...
def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ...
def autoHideTitleLabel(self) -> typing.Optional["CElidingLabel"]: ...
def button(self, which: "TitleBarButton") -> typing.Optional["CTitleBarButton"]: ...
def tabBar(self) -> typing.Optional["CDockAreaTabBar"]: ...
def markTabsMenuOutdated(self) -> None: ...
def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ...
def contextMenuEvent(self, event: typing.Optional[QtGui.QContextMenuEvent]) -> None: ...
def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
class CDockAreaWidget(QtWidgets.QFrame):
class eDockAreaFlag(enum.Enum):
HideSingleWidgetTitleBar = ...
DefaultFlags = ...
def __init__(
self,
DockManager: typing.Optional[CDockManager],
parent: typing.Optional[CDockContainerWidget],
) -> None: ...
viewToggled: typing.ClassVar[Signal]
currentChanged: typing.ClassVar[Signal]
currentChanging: typing.ClassVar[Signal]
tabBarClicked: typing.ClassVar[Signal]
def setFloating(self) -> None: ...
def closeOtherAreas(self) -> None: ...
def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ...
def setAutoHide(
self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ...
) -> None: ...
def closeArea(self) -> None: ...
def setCurrentIndex(self, index: int) -> None: ...
def isTopLevelArea(self) -> bool: ...
def containsCentralWidget(self) -> bool: ...
def isCentralWidgetArea(self) -> bool: ...
def setDockAreaFlag(self, Flag: "CDockAreaWidget.eDockAreaFlag", On: bool) -> None: ...
def setDockAreaFlags(self, Flags: "CDockAreaWidget.eDockAreaFlag") -> None: ...
def dockAreaFlags(self) -> "CDockAreaWidget.eDockAreaFlag": ...
def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ...
def allowedAreas(self) -> "DockWidgetArea": ...
def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ...
def setVisible(self, Visible: bool) -> None: ...
def titleBarButton(
self, which: "TitleBarButton"
) -> typing.Optional[QtWidgets.QAbstractButton]: ...
def features(self, Mode: "eBitwiseOperator" = ...) -> "CDockWidget.DockWidgetFeature": ...
@staticmethod
def restoreState(
Stream: "CDockingStateReader",
Testing: bool,
ParentContainer: typing.Optional["CDockContainerWidget"],
) -> typing.Tuple[bool, typing.Optional["CDockAreaWidget"]]: ...
def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ...
def setCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def currentDockWidget(self) -> typing.Optional["CDockWidget"]: ...
def indexOfFirstOpenDockWidget(self) -> int: ...
def currentIndex(self) -> int: ...
def dockWidget(self, Index: int) -> typing.Optional["CDockWidget"]: ...
def openedDockWidgets(self) -> list["CDockWidget"]: ...
def openDockWidgetsCount(self) -> int: ...
def dockWidgets(self) -> list["CDockWidget"]: ...
def dockWidgetsCount(self) -> int: ...
def contentAreaGeometry(self) -> QtCore.QRect: ...
def titleBarGeometry(self) -> QtCore.QRect: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def setAutoHideDockContainer(self, a0: typing.Optional["CAutoHideDockContainer"]) -> None: ...
def isAutoHide(self) -> bool: ...
def parentSplitter(self) -> typing.Optional["CDockSplitter"]: ...
def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ...
def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ...
def dockManager(self) -> typing.Optional["CDockManager"]: ...
def toggleView(self, Open: bool) -> None: ...
def updateTitleBarButtonVisibility(self, IsTopLevel: bool) -> None: ...
def markTitleBarMenuOutdated(self) -> None: ...
def internalSetCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def updateTitleBarVisibility(self) -> None: ...
def hideAreaWithNoVisibleContent(self) -> None: ...
def index(self, DockWidget: typing.Optional["CDockWidget"]) -> int: ...
def nextOpenDockWidget(
self, DockWidget: typing.Optional["CDockWidget"]
) -> typing.Optional["CDockWidget"]: ...
def toggleDockWidgetView(
self, DockWidget: typing.Optional["CDockWidget"], Open: bool
) -> None: ...
def removeDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def insertDockWidget(
self, index: int, DockWidget: typing.Optional["CDockWidget"], Activate: bool = ...
) -> None: ...
class CDockContainerWidget(QtWidgets.QFrame):
def __init__(
self,
DockManager: typing.Optional["CDockManager"],
parent: typing.Optional[QtWidgets.QWidget] = ...,
) -> None: ...
dockAreaViewToggled: typing.ClassVar[Signal]
dockAreasRemoved: typing.ClassVar[Signal]
autoHideWidgetCreated: typing.ClassVar[Signal]
dockAreasAdded: typing.ClassVar[Signal]
def dockManager(self) -> typing.Optional["CDockManager"]: ...
def contentRectGlobal(self) -> QtCore.QRect: ...
def contentRect(self) -> QtCore.QRect: ...
def autoHideWidgets(self) -> list["CAutoHideDockContainer"]: ...
def autoHideSideBar(self, area: "SideBarLocation") -> typing.Optional["CAutoHideSideBar"]: ...
def closeOtherAreas(self, KeepOpenArea: typing.Optional["CDockAreaWidget"]) -> None: ...
def floatingWidget(self) -> typing.Optional["CFloatingDockContainer"]: ...
def features(self) -> "CDockWidget.DockWidgetFeature": ...
def dumpLayout(self) -> None: ...
def isFloating(self) -> bool: ...
def visibleDockAreaCount(self) -> int: ...
def dockAreaCount(self) -> int: ...
def hasTopLevelDockWidget(self) -> bool: ...
def openedDockWidgets(self) -> list["CDockWidget"]: ...
def openedDockAreas(self) -> list["CDockAreaWidget"]: ...
def dockArea(self, Index: int) -> typing.Optional["CDockAreaWidget"]: ...
def dockAreaAt(self, GlobalPos: QtCore.QPoint) -> typing.Optional["CDockAreaWidget"]: ...
def isInFrontOf(self, Other: typing.Optional["CDockContainerWidget"]) -> bool: ...
def zOrderIndex(self) -> int: ...
def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ...
def addDockWidget(
self,
area: "DockWidgetArea",
Dockwidget: typing.Optional["CDockWidget"],
DockAreaWidget: typing.Optional["CDockAreaWidget"] = ...,
Index: int = ...,
) -> typing.Optional["CDockAreaWidget"]: ...
def handleAutoHideWidgetEvent(
self, e: typing.Optional[QtCore.QEvent], w: typing.Optional[QtWidgets.QWidget]
) -> None: ...
def removeAutoHideWidget(
self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"]
) -> None: ...
def registerAutoHideWidget(
self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"]
) -> None: ...
def updateSplitterHandles(self, splitter: typing.Optional[QtWidgets.QSplitter]) -> None: ...
def dockWidgets(self) -> list["CDockWidget"]: ...
def topLevelDockArea(self) -> typing.Optional["CDockAreaWidget"]: ...
def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ...
def lastAddedDockAreaWidget(
self, area: "DockWidgetArea"
) -> typing.Optional["CDockAreaWidget"]: ...
def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ...
def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ...
def removeAllDockAreas(self) -> list[CDockAreaWidget]: ...
def removeDockArea(self, area: typing.Optional["CDockAreaWidget"]) -> None: ...
def addDockArea(
self, DockAreaWidget: typing.Optional["CDockAreaWidget"], area: "DockWidgetArea" = ...
) -> None: ...
def dropWidget(
self,
Widget: typing.Optional[QtWidgets.QWidget],
DropArea: "DockWidgetArea",
TargetAreaWidget: typing.Optional["CDockAreaWidget"],
TabIndex: int = ...,
) -> None: ...
def dropFloatingWidget(
self, FloatingWidget: typing.Optional["CFloatingDockContainer"], TargetPos: QtCore.QPoint
) -> None: ...
def createRootSplitter(self) -> None: ...
def createAndSetupAutoHideContainer(
self,
area: "SideBarLocation",
DockWidget: typing.Optional["CDockWidget"],
TabIndex: int = ...,
) -> typing.Optional["CAutoHideDockContainer"]: ...
def rootSplitter(self) -> typing.Optional["CDockSplitter"]: ...
def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ...
class CDockingStateReader(QtCore.QXmlStreamReader):
def __init__(self) -> None: ...
def fileVersion(self) -> int: ...
def setFileVersion(self, FileVersion: int) -> None: ...
class CDockFocusController(QtCore.QObject):
def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ...
def setDockWidgetFocused(self, focusedNow: typing.Optional["CDockWidget"]) -> None: ...
def setDockWidgetTabPressed(self, Value: bool) -> None: ...
def clearDockWidgetFocus(self, dockWidget: typing.Optional["CDockWidget"]) -> None: ...
def setDockWidgetTabFocused(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ...
def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ...
def notifyFloatingWidgetDrop(
self, FloatingWidget: typing.Optional["CFloatingDockContainer"]
) -> None: ...
def notifyWidgetOrAreaRelocation(
self, RelocatedWidget: typing.Optional[QtWidgets.QWidget]
) -> None: ...
class CDockManager(CDockContainerWidget):
class eConfigParam(enum.Enum):
AutoHideOpenOnDragHoverDelay_ms = ...
ConfigParamCount = ...
class eAutoHideFlag(enum.Enum):
AutoHideFeatureEnabled = ...
DockAreaHasAutoHideButton = ...
AutoHideButtonTogglesArea = ...
AutoHideButtonCheckable = ...
AutoHideSideBarsIconOnly = ...
AutoHideShowOnMouseOver = ...
AutoHideCloseButtonCollapsesDock = ...
AutoHideHasCloseButton = ...
AutoHideHasMinimizeButton = ...
AutoHideOpenOnDragHover = ...
AutoHideCloseOnOutsideMouseClick = ...
DefaultAutoHideConfig = ...
class eConfigFlag(enum.Enum):
ActiveTabHasCloseButton = ...
DockAreaHasCloseButton = ...
DockAreaCloseButtonClosesTab = ...
OpaqueSplitterResize = ...
XmlAutoFormattingEnabled = ...
XmlCompressionEnabled = ...
TabCloseButtonIsToolButton = ...
AllTabsHaveCloseButton = ...
RetainTabSizeWhenCloseButtonHidden = ...
DragPreviewIsDynamic = ...
DragPreviewShowsContentPixmap = ...
DragPreviewHasWindowFrame = ...
AlwaysShowTabs = ...
DockAreaHasUndockButton = ...
DockAreaHasTabsMenuButton = ...
DockAreaHideDisabledButtons = ...
DockAreaDynamicTabsMenuButtonVisibility = ...
FloatingContainerHasWidgetTitle = ...
FloatingContainerHasWidgetIcon = ...
HideSingleCentralWidgetTitleBar = ...
FocusHighlighting = ...
EqualSplitOnInsertion = ...
FloatingContainerForceNativeTitleBar = ...
FloatingContainerForceQWidgetTitleBar = ...
MiddleMouseButtonClosesTab = ...
DisableTabTextEliding = ...
ShowTabTextOnlyForActiveTab = ...
DoubleClickUndocksWidget = ...
DefaultDockAreaButtons = ...
DefaultBaseConfig = ...
DefaultOpaqueConfig = ...
DefaultNonOpaqueConfig = ...
NonOpaqueWithWindowFrame = ...
class eViewMenuInsertionOrder(enum.Enum):
MenuSortedByInsertion = ...
MenuAlphabeticallySorted = ...
def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ...
focusedDockWidgetChanged: typing.ClassVar[Signal]
dockWidgetRemoved: typing.ClassVar[Signal]
dockWidgetAboutToBeRemoved: typing.ClassVar[Signal]
dockWidgetAdded: typing.ClassVar[Signal]
dockAreaCreated: typing.ClassVar[Signal]
floatingWidgetCreated: typing.ClassVar[Signal]
perspectiveOpened: typing.ClassVar[Signal]
openingPerspective: typing.ClassVar[Signal]
stateRestored: typing.ClassVar[Signal]
restoringState: typing.ClassVar[Signal]
perspectivesRemoved: typing.ClassVar[Signal]
perspectiveListLoaded: typing.ClassVar[Signal]
perspectiveListChanged: typing.ClassVar[Signal]
def hideManagerAndFloatingWidgets(self) -> None: ...
def setDockWidgetFocused(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def openPerspective(self, PerspectiveName: typing.Optional[str]) -> None: ...
def endLeavingMinimizedState(self) -> None: ...
def lockDockWidgetFeaturesGlobally(
self, Features: CDockWidget.DockWidgetFeature = ...
) -> None: ...
def globallyLockedDockWidgetFeatures(self) -> CDockWidget.DockWidgetFeature: ...
def dockWidgetToolBarIconSize(self, State: CDockWidget.eState) -> QtCore.QSize: ...
def setDockWidgetToolBarIconSize(
self, IconSize: QtCore.QSize, State: CDockWidget.eState
) -> None: ...
def dockWidgetToolBarStyle(self, State: CDockWidget.eState) -> QtCore.Qt.ToolButtonStyle: ...
def setDockWidgetToolBarStyle(
self, Style: QtCore.Qt.ToolButtonStyle, State: CDockWidget.eState
) -> None: ...
@staticmethod
def floatingContainersTitle() -> str: ...
@staticmethod
def setFloatingContainersTitle(Title: typing.Optional[str]) -> None: ...
def setSplitterSizes(
self,
ContainedArea: typing.Optional["CDockAreaWidget"],
sizes: collections.abc.Iterable[int],
) -> None: ...
def splitterSizes(self, ContainedArea: typing.Optional["CDockAreaWidget"]) -> list[int]: ...
def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ...
@staticmethod
def startDragDistance() -> int: ...
def isLeavingMinimizedState(self) -> bool: ...
def isRestoringState(self) -> bool: ...
def setViewMenuInsertionOrder(self, Order: "CDockManager.eViewMenuInsertionOrder") -> None: ...
def viewMenu(self) -> typing.Optional[QtWidgets.QMenu]: ...
def addToggleViewActionToMenu(
self,
ToggleViewAction: typing.Optional[QtGui.QAction],
Group: typing.Optional[str] = ...,
GroupIcon: QtGui.QIcon = ...,
) -> typing.Optional[QtGui.QAction]: ...
def setCentralWidget(
self, widget: typing.Optional["CDockWidget"]
) -> typing.Optional["CDockAreaWidget"]: ...
def centralWidget(self) -> typing.Optional["CDockWidget"]: ...
def loadPerspectives(self, Settings: QtCore.QSettings) -> None: ...
def savePerspectives(self, Settings: QtCore.QSettings) -> None: ...
def perspectiveNames(self) -> list[str]: ...
def removePerspectives(self, Names: collections.abc.Iterable[typing.Optional[str]]) -> None: ...
def removePerspective(self, Name: typing.Optional[str]) -> None: ...
def addPerspective(self, UniquePrespectiveName: typing.Optional[str]) -> None: ...
def restoreState(
self,
state: typing.Union[QtCore.QByteArray, bytes, bytearray, memoryview],
version: int = ...,
) -> bool: ...
def saveState(self, version: int = ...) -> QtCore.QByteArray: ...
def zOrderIndex(self) -> int: ...
def floatingWidgets(self) -> list["CFloatingDockContainer"]: ...
def dockContainers(self) -> list["CDockContainerWidget"]: ...
def dockWidgetsMap(self) -> dict[str, CDockWidget]: ...
def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ...
def findDockWidget(
self, ObjectName: typing.Optional[str]
) -> typing.Optional["CDockWidget"]: ...
def addDockWidgetFloating(
self, DockWidget: typing.Optional["CDockWidget"]
) -> typing.Optional["CFloatingDockContainer"]: ...
def addDockWidgetTabToArea(
self,
Dockwidget: typing.Optional["CDockWidget"],
DockAreaWidget: typing.Optional["CDockAreaWidget"],
Index: int = ...,
) -> typing.Optional["CDockAreaWidget"]: ...
def addDockWidgetTab(
self, area: "DockWidgetArea", Dockwidget: typing.Optional["CDockWidget"]
) -> typing.Optional["CDockAreaWidget"]: ...
def addAutoHideDockWidgetToContainer(
self,
Location: "SideBarLocation",
Dockwidget: typing.Optional["CDockWidget"],
DockContainerWidget: typing.Optional["CDockContainerWidget"],
) -> typing.Optional["CAutoHideDockContainer"]: ...
def addAutoHideDockWidget(
self, Location: "SideBarLocation", Dockwidget: typing.Optional["CDockWidget"]
) -> typing.Optional["CAutoHideDockContainer"]: ...
def addDockWidgetToContainer(
self,
area: "DockWidgetArea",
Dockwidget: typing.Optional["CDockWidget"],
DockContainerWidget: typing.Optional["CDockContainerWidget"] = ...,
) -> typing.Optional["CDockAreaWidget"]: ...
def addDockWidget(
self,
area: "DockWidgetArea",
Dockwidget: typing.Optional["CDockWidget"],
DockAreaWidget: typing.Optional["CDockAreaWidget"] = ...,
Index: int = ...,
) -> typing.Optional["CDockAreaWidget"]: ...
@staticmethod
def iconProvider() -> "CIconProvider": ...
@staticmethod
def configParam(Param: "CDockManager.eConfigParam", Default: typing.Any) -> typing.Any: ...
@staticmethod
def setConfigParam(Param: "CDockManager.eConfigParam", Value: typing.Any) -> None: ...
@staticmethod
def testAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag") -> bool: ...
@staticmethod
def setAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag", On: bool = ...) -> None: ...
@staticmethod
def setAutoHideConfigFlags(Flags: "CDockManager.eAutoHideFlag") -> None: ...
@staticmethod
def autoHideConfigFlags() -> "CDockManager.eAutoHideFlag": ...
@staticmethod
def testConfigFlag(Flag: "CDockManager.eConfigFlag") -> bool: ...
@staticmethod
def setConfigFlag(Flag: "CDockManager.eConfigFlag", On: bool = ...) -> None: ...
@staticmethod
def setConfigFlags(Flags: "CDockManager.eConfigFlag") -> None: ...
@staticmethod
def configFlags() -> "CDockManager.eConfigFlag": ...
def setComponentsFactory(self, Factory: typing.Optional["CDockComponentsFactory"]) -> None: ...
def componentsFactory(self) -> typing.Optional["CDockComponentsFactory"]: ...
def createDockWidget(
self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ...
) -> typing.Optional["CDockWidget"]: ...
def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ...
def notifyFloatingWidgetDrop(
self, FloatingWidget: typing.Optional["CFloatingDockContainer"]
) -> None: ...
def notifyWidgetOrAreaRelocation(
self, RelocatedWidget: typing.Optional[QtWidgets.QWidget]
) -> None: ...
def dockAreaOverlay(self) -> typing.Optional["CDockOverlay"]: ...
def containerOverlay(self) -> typing.Optional["CDockOverlay"]: ...
def removeDockContainer(
self, DockContainer: typing.Optional["CDockContainerWidget"]
) -> None: ...
def registerDockContainer(
self, DockContainer: typing.Optional["CDockContainerWidget"]
) -> None: ...
def removeFloatingWidget(
self, FloatingWidget: typing.Optional["CFloatingDockContainer"]
) -> None: ...
def registerFloatingWidget(
self, FloatingWidget: typing.Optional["CFloatingDockContainer"]
) -> None: ...
class CDockOverlay(QtWidgets.QFrame):
class eMode(enum.Enum):
ModeDockAreaOverlay = ...
ModeContainerOverlay = ...
def __init__(
self, parent: typing.Optional[QtWidgets.QWidget], Mode: "CDockOverlay.eMode" = ...
) -> None: ...
def hideEvent(self, e: typing.Optional[QtGui.QHideEvent]) -> None: ...
def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ...
def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ...
def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ...
def dropOverlayRect(self) -> QtCore.QRect: ...
def dropPreviewEnabled(self) -> bool: ...
def enableDropPreview(self, Enable: bool) -> None: ...
def hideOverlay(self) -> None: ...
def showOverlay(self, target: typing.Optional[QtWidgets.QWidget]) -> "DockWidgetArea": ...
def visibleDropAreaUnderCursor(self) -> "DockWidgetArea": ...
def tabIndexUnderCursor(self) -> int: ...
def dropAreaUnderCursor(self) -> "DockWidgetArea": ...
def allowedAreas(self) -> "DockWidgetArea": ...
def setAllowedArea(self, area: "DockWidgetArea", Enable: bool) -> None: ...
def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ...
class CDockOverlayCross(QtWidgets.QWidget):
class eIconColor(enum.Enum):
FrameColor = ...
WindowBackgroundColor = ...
OverlayColor = ...
ArrowColor = ...
ShadowColor = ...
def __init__(self, overlay: typing.Optional["CDockOverlay"]) -> None: ...
def setIconColors(self, Colors: typing.Optional[str]) -> None: ...
def updatePosition(self) -> None: ...
def reset(self) -> None: ...
def updateOverlayIcons(self) -> None: ...
def setupOverlayCross(self, Mode: "CDockOverlay.eMode") -> None: ...
def cursorLocation(self) -> "DockWidgetArea": ...
def setIconColor(
self,
ColorIndex: "CDockOverlayCross.eIconColor",
Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int],
) -> None: ...
def setAreaWidgets(self, widgets: dict["DockWidgetArea", QtWidgets.QWidget]) -> None: ...
def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ...
def setIconShadowColor(
self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int]
) -> None: ...
def setIconArrowColor(
self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int]
) -> None: ...
def setIconOverlayColor(
self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int]
) -> None: ...
def setIconBackgroundColor(
self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int]
) -> None: ...
def setIconFrameColor(
self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int]
) -> None: ...
@typing.overload
def iconColor(self) -> QtGui.QColor: ...
@typing.overload
def iconColor(self, ColorIndex: "CDockOverlayCross.eIconColor") -> QtGui.QColor: ...
def iconColors(self) -> str: ...
class CDockSplitter(QtWidgets.QSplitter):
@typing.overload
def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ...
@typing.overload
def __init__(
self, orientation: QtCore.Qt.Orientation, parent: typing.Optional[QtWidgets.QWidget] = ...
) -> None: ...
def isResizingWithContainer(self) -> bool: ...
def lastWidget(self) -> typing.Optional[QtWidgets.QWidget]: ...
def firstWidget(self) -> typing.Optional[QtWidgets.QWidget]: ...
def hasVisibleContent(self) -> bool: ...
class CDockWidgetTab(QtWidgets.QFrame):
def __init__(
self,
DockWidget: typing.Optional["CDockWidget"],
parent: typing.Optional[QtWidgets.QWidget] = ...,
) -> None: ...
elidedChanged: typing.ClassVar[Signal]
moved: typing.ClassVar[Signal]
closeOtherTabsRequested: typing.ClassVar[Signal]
closeRequested: typing.ClassVar[Signal]
clicked: typing.ClassVar[Signal]
activeTabChanged: typing.ClassVar[Signal]
def setVisible(self, visible: bool) -> None: ...
def buildContextMenu(
self, menu: typing.Optional[QtWidgets.QMenu] = ...
) -> typing.Optional[QtWidgets.QMenu]: ...
def dragState(self) -> "eDragState": ...
def setIconSize(self, Size: QtCore.QSize) -> None: ...
def iconSize(self) -> QtCore.QSize: ...
def updateStyle(self) -> None: ...
def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ...
def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ...
def isClosable(self) -> bool: ...
def isTitleElided(self) -> bool: ...
def setText(self, title: typing.Optional[str]) -> None: ...
def text(self) -> str: ...
def icon(self) -> QtGui.QIcon: ...
def setIcon(self, Icon: QtGui.QIcon) -> None: ...
def dockWidget(self) -> typing.Optional["CDockWidget"]: ...
def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ...
def setDockAreaWidget(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ...
def setActiveTab(self, active: bool) -> None: ...
def isActiveTab(self) -> bool: ...
def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ...
def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
class CElidingLabel(QtWidgets.QLabel):
@typing.overload
def __init__(
self, parent: typing.Optional[QtWidgets.QWidget] = ..., f: QtCore.Qt.WindowType = ...
) -> None: ...
@typing.overload
def __init__(
self,
text: typing.Optional[str],
parent: typing.Optional[QtWidgets.QWidget] = ...,
f: QtCore.Qt.WindowType = ...,
) -> None: ...
elidedChanged: typing.ClassVar[Signal]
doubleClicked: typing.ClassVar[Signal]
clicked: typing.ClassVar[Signal]
def text(self) -> str: ...
def setText(self, text: typing.Optional[str]) -> None: ...
def sizeHint(self) -> QtCore.QSize: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def isElided(self) -> bool: ...
def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ...
def elideMode(self) -> QtCore.Qt.TextElideMode: ...
def mouseDoubleClickEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ...
def mouseReleaseEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ...
class IFloatingWidget:
@typing.overload
def __init__(self) -> None: ...
@typing.overload
def __init__(self, a0: "IFloatingWidget") -> None: ...
def finishDragging(self) -> None: ...
def moveFloating(self) -> None: ...
def startFloating(
self,
DragStartMousePos: QtCore.QPoint,
Size: QtCore.QSize,
DragState: "eDragState",
MouseEventHandler: typing.Optional[QtWidgets.QWidget],
) -> None: ...
class CFloatingDockContainer(QtWidgets.QWidget, IFloatingWidget):
@typing.overload
def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ...
@typing.overload
def __init__(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ...
@typing.overload
def __init__(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ...
def finishDropOperation(self) -> None: ...
def dockWidgets(self) -> list["CDockWidget"]: ...
def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ...
def hasTopLevelDockWidget(self) -> bool: ...
def isClosable(self) -> bool: ...
def startDragging(
self,
DragStartMousePos: QtCore.QPoint,
Size: QtCore.QSize,
MouseEventHandler: typing.Optional[QtWidgets.QWidget],
) -> None: ...
def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ...
def moveEvent(self, event: typing.Optional[QtGui.QMoveEvent]) -> None: ...
def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ...
def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ...
def hideEvent(self, event: typing.Optional[QtGui.QHideEvent]) -> None: ...
def closeEvent(self, event: typing.Optional[QtGui.QCloseEvent]) -> None: ...
def changeEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ...
def updateWindowTitle(self) -> None: ...
def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ...
def moveFloating(self) -> None: ...
def initFloatingGeometry(
self, DragStartMousePos: QtCore.QPoint, Size: QtCore.QSize
) -> None: ...
def deleteContent(self) -> None: ...
def finishDragging(self) -> None: ...
def startFloating(
self,
DragStartMousePos: QtCore.QPoint,
Size: QtCore.QSize,
DragState: "eDragState",
MouseEventHandler: typing.Optional[QtWidgets.QWidget],
) -> None: ...
class CFloatingDragPreview(QtWidgets.QWidget, IFloatingWidget):
@typing.overload
def __init__(
self,
Content: typing.Optional[QtWidgets.QWidget],
parent: typing.Optional[QtWidgets.QWidget],
) -> None: ...
@typing.overload
def __init__(self, Content: typing.Optional["CDockWidget"]) -> None: ...
@typing.overload
def __init__(self, Content: typing.Optional["CDockAreaWidget"]) -> None: ...
draggingCanceled: typing.ClassVar[Signal]
def cleanupAutoHideContainerWidget(self, ContainerDropArea: "DockWidgetArea") -> None: ...
def finishDragging(self) -> None: ...
def moveFloating(self) -> None: ...
def startFloating(
self,
DragStartMousePos: QtCore.QPoint,
Size: QtCore.QSize,
DragState: "eDragState",
MouseEventHandler: typing.Optional[QtWidgets.QWidget],
) -> None: ...
def eventFilter(
self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent]
) -> bool: ...
def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ...
class CIconProvider:
@typing.overload
def __init__(self) -> None: ...
@typing.overload
def __init__(self, a0: CIconProvider) -> None: ...
def registerCustomIcon(self, IconId: "eIcon", icon: QtGui.QIcon) -> None: ...
def customIcon(self, IconId: "eIcon") -> QtGui.QIcon: ...
class CResizeHandle(QtWidgets.QFrame):
def __init__(
self, HandlePosition: QtCore.Qt.Edge, parent: typing.Optional[QtWidgets.QWidget]
) -> None: ...
def opaqueResize(self) -> bool: ...
def setOpaqueResize(self, opaque: bool = ...) -> None: ...
def setMaxResizeSize(self, MaxSize: int) -> None: ...
def setMinResizeSize(self, MinSize: int) -> None: ...
def isResizing(self) -> bool: ...
def sizeHint(self) -> QtCore.QSize: ...
def orientation(self) -> QtCore.Qt.Orientation: ...
def handlePostion(self) -> QtCore.Qt.Edge: ...
def setHandlePosition(self, HandlePosition: QtCore.Qt.Edge) -> None: ...
def mouseReleaseEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mousePressEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ...
def mouseMoveEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ...

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import enum
# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called
class SideBarLocation(enum.Enum):
SideBarTop = ...
SideBarLeft = ...
SideBarRight = ...
SideBarBottom = ...
SideBarNone = ...
class eBitwiseOperator(enum.Enum):
BitwiseAnd = ...
BitwiseOr = ...
class eIcon(enum.Enum):
TabCloseIcon = ...
AutoHideIcon = ...
DockAreaMenuIcon = ...
DockAreaUndockIcon = ...
DockAreaCloseIcon = ...
DockAreaMinimizeIcon = ...
IconCount = ...
class eDragState(enum.Enum):
DraggingInactive = ...
DraggingMousePressed = ...
DraggingTab = ...
DraggingFloatingWidget = ...
class TitleBarButton(enum.Enum):
TitleBarButtonTabsMenu = ...
TitleBarButtonUndock = ...
TitleBarButtonClose = ...
TitleBarButtonAutoHide = ...
TitleBarButtonMinimize = ...
class eTabIndex(enum.Enum):
TabDefaultInsertIndex = ...
TabInvalidIndex = ...
class DockWidgetArea(enum.Enum):
NoDockWidgetArea = ...
LeftDockWidgetArea = ...
RightDockWidgetArea = ...
TopDockWidgetArea = ...
BottomDockWidgetArea = ...
CenterDockWidgetArea = ...
LeftAutoHideArea = ...
RightAutoHideArea = ...
TopAutoHideArea = ...
BottomAutoHideArea = ...
InvalidDockWidgetArea = ...
OuterDockAreas = ...
AutoHideDockAreas = ...
AllDockAreas = ...

View File

@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
@@ -20,6 +21,8 @@ class AbortButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = AbortButton(parent)
return t

View File

@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = True
RPC = False
def __init__(
self,
@@ -38,9 +38,6 @@ class AbortButton(BECWidget, QWidget):
else:
self.button = QPushButton()
self.button.setText("Abort")
self.button.setStyleSheet(
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.abort_scan)
self.layout.addWidget(self.button)

View File

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

View File

@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
@@ -20,6 +21,8 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResetButton(parent)
return t
@@ -48,7 +51,7 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ResetButton"
def toolTip(self):
return "A button that reset the scan queue."
return "A button that resets the scan queue."
def whatsThis(self):
return self.toolTip()

View File

@@ -2,6 +2,7 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
@@ -20,6 +21,8 @@ class ResumeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResumeButton(parent)
return t

View File

@@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
RPC = True
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -31,9 +31,7 @@ class StopButton(BECWidget, QWidget):
self.button = QPushButton()
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self.button.setText("Stop")
self.button.setStyleSheet(
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
)
self.button.setProperty("variant", "danger")
self.button.clicked.connect(self.stop_scan)
self.layout.addWidget(self.button)

View File

@@ -1,10 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
@@ -15,8 +14,6 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -24,6 +21,8 @@ class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = StopButton(parent)
return t

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