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

Compare commits

...

207 Commits

Author SHA1 Message Date
wyzula_j 5dc42895aa WIP waveform with super bad convergence 2025-03-14 15:09:01 +01:00
wyzula_j 115da10d78 WIP waveform request dap using parameters of the previous fit 2025-03-14 14:58:16 +01:00
wyzula_j 0753e359c6 fix(scatter_waveform,waveform): Added QTimer to fetch the last data points after 500ms 2025-03-14 13:29:27 +01:00
wyzula_j 69bc40a8e4 feat(scatter_waveform): scatter waveform widget based on new Plotbase 2025-03-14 10:53:44 +01:00
wyzula_j 75dbbf7663 refactor(tests): create dummy scan item moved to client_mocks.py 2025-03-14 10:53:44 +01:00
wyzula_j a5be450590 fix(plot_base): improved handling of matplotlib exporter errors 2025-03-14 10:53:44 +01:00
wyzula_j 7127bba1de fix(plot_base): ability to set y label suffix 2025-03-14 10:53:44 +01:00
wyzula_j 6b265b1d30 refactor(image_widget): old BECImageWidget removed 2025-03-14 10:51:34 +01:00
wyzula_j 0a9d2c4d18 feat(image): new Image widget based on new PlotBase 2025-03-14 10:51:34 +01:00
wyzula_j 37f736954f fix(toolbar): update action check handling logic for SwitchableToolBarAction 2025-03-13 22:42:02 +01:00
wyzula_j bea107b036 fix(plot_base): enable popup property fixed 2025-03-13 22:42:02 +01:00
wyzula_j 532f2957b4 fix(crosshair): adapted for 2D image 2025-03-13 22:42:02 +01:00
appel_c 7a676d6078 test: disable test_bec_dock_rpc_e2e module, issue to fix this created #450 2025-03-13 17:50:41 +01:00
appel_c cc3cb8ccb1 tests: fix e2e tests for namespace refactoring 2025-03-13 17:28:39 +01:00
appel_c 81c306611d refactor: cleanup MR 2025-03-13 17:28:39 +01:00
wakonig_k c5b384c7f5 feat!: namespace update for gui, dock_area and docks. 2025-03-13 17:25:35 +01:00
wyzula_j a186e66b92 docs(plot_base): update docstrings for properties and setters 2025-03-13 17:20:43 +01:00
wyzula_j 183104456f refactor(waveform_widget): removed and replaced by Waveform 2025-03-13 17:20:43 +01:00
wyzula_j d1a6a59b86 test(plot_indicators): tests adapted to not be dependent on BECWaveformWidget 2025-03-13 17:20:43 +01:00
wyzula_j 1562000b37 fix(plot_indicators): cleanup adjusted 2025-03-13 17:20:43 +01:00
wyzula_j 2b37ac154e feat(waveform): new Waveform widget based on NextGen PlotBase 2025-03-13 17:20:43 +01:00
wyzula_j 59bc5996df fix(entry_validator): validator reports list of signal if user chooses the wrong one 2025-03-13 17:20:43 +01:00
wyzula_j 06d7f239ed fix(plot_base): update mouse mode state on mode change 2025-03-13 17:20:43 +01:00
wyzula_j 5920b26c02 fix(plot_base): aspect ratio removed from the PlotBase 2025-03-13 17:20:43 +01:00
wyzula_j 9439e4a275 fix(plot_base): inner and outer axis setting in popup mode 2025-03-13 17:20:43 +01:00
wyzula_j 9342bffe85 fix(plot_base): fix cleanup of popups if popups are still open when PlotBase is closed 2025-03-13 17:20:43 +01:00
wyzula_j 47ee8e5599 fix(lmfit_dialog_vertical): vertical sizePolicy fixed 2025-03-13 17:20:43 +01:00
wakonig_k d86ef4e763 ci: add e2e job for pre_release branches 2025-03-13 16:44:57 +01:00
wakonig_k 6cf39b3796 ci: fix conda channels for PSI policy change 2025-03-13 16:13:44 +01:00
semantic-release 15e11b287d 1.25.0
Automatically generated by python-semantic-release
2025-03-07 15:19:37 +00:00
wyzula_j 7cbebbb1f0 feat(waveform): add slice handling and reset functionality for async updates 2025-03-07 15:44:46 +01:00
semantic-release 66f4f9bfa8 1.24.5
Automatically generated by python-semantic-release
2025-03-06 14:51:03 +00:00
appel_c 66c6c7fa50 fix: add support for additional keyword arguments in widget constructors 2025-03-06 15:39:16 +01:00
semantic-release 31c3337300 1.24.4
Automatically generated by python-semantic-release
2025-03-05 19:59:54 +00:00
wakonig_k 2c506ee3c8 fix(cli/server): handle RedisError during heartbeat emission to properly close the app even if the Redis connection is lost 2025-03-05 20:41:33 +01:00
semantic-release 25423f4a3a 1.24.3
Automatically generated by python-semantic-release
2025-03-05 09:46:53 +00:00
wyzula_j fa91366dcb fix(multi_waveform): update on_async_readback to use structured metadata for async updates with "add" instead of "extend" 2025-03-04 22:31:14 +01:00
semantic-release 4db0f9f10c 1.24.2
Automatically generated by python-semantic-release
2025-02-27 10:08:57 +00:00
wyzula_j 46b1a228be fix(e2e): added wait time to flaky e2e 2025-02-27 10:54:36 +01:00
semantic-release 531018b0ac 1.24.1
Automatically generated by python-semantic-release
2025-02-26 21:06:09 +00:00
wyzula_j 8679b5f08b test: extended test coverage for axis settings, plot base and qt toolbar action 2025-02-26 21:54:33 +01:00
wyzula_j 6f2c2401ac refactor(plot_base): toolbar buttons adapted for the Switch actions from toolbar; plot export and mouse modes consolidated into one switch button 2025-02-26 21:54:33 +01:00
wyzula_j 6d1106e33e fix(toolbar): Switch Actions for default checked actions fixed 2025-02-26 21:54:33 +01:00
wyzula_j 90a184643a refactor(axis_settings): spinbox migrated to new BECSpinBoxes 2025-02-26 21:54:33 +01:00
wyzula_j 3aa2f2225f fix(plot_base): ability to choose between popup or side panel gui mode 2025-02-26 21:54:33 +01:00
semantic-release f54e69f1cf 1.24.0
Automatically generated by python-semantic-release
2025-02-26 11:20:07 +00:00
perl_d 7309c1dede feat: add metadata widget to scan control 2025-02-26 12:08:32 +01:00
perl_d 1c0021f98b fix: make scan metadata use collapsible frame 2025-02-26 12:08:32 +01:00
perl_d d32952a0d5 style: isort 2025-02-26 12:08:32 +01:00
perl_d 5206528fec feat: add expandable/collapsible frame 2025-02-26 12:08:32 +01:00
perl_d 42665b69c5 fix: replace add'l md table w/ tree view 2025-02-26 12:08:32 +01:00
semantic-release 209c898e3d 1.23.1
Automatically generated by python-semantic-release
2025-02-24 13:54:40 +00:00
perl_d 6a43554f3b fix: update redis mock for changes in bec 2025-02-24 14:43:02 +01:00
semantic-release 95c931af0b 1.23.0
Automatically generated by python-semantic-release
2025-02-24 10:00:25 +00:00
wyzula_j f19d9485df feat(bec_spin_box): double spin box with setting inside for defining decimals 2025-02-24 10:49:10 +01:00
semantic-release 575c988c4f 1.22.0
Automatically generated by python-semantic-release
2025-02-19 16:54:57 +00:00
wyzula_j 6b08f7cfb2 refactor(toolbar): added dark mode button for testing appearance for the toolbar example 2025-02-19 17:43:49 +01:00
wyzula_j 6ae33a23a6 test(toolbar): blocking tests fixed 2025-02-19 17:08:56 +01:00
wyzula_j facb8c30ff fix(toolbar): update_separators logic updated, there cannot be two separators next to each other 2025-02-19 15:44:44 +01:00
wyzula_j 333570ba2f feat(toolbar): SwitchableToolBarButton 2025-02-19 15:42:31 +01:00
wyzula_j ef36a7124d fix(toolbar): widget actions are more compact 2025-02-19 15:02:17 +01:00
wyzula_j c2c022154b fix(toolbar): QMenu Icons are visible 2025-02-19 15:02:17 +01:00
wyzula_j 4c4f1592c2 fix(modular_toolbar): add action to an already existing bundle 2025-02-19 15:02:17 +01:00
semantic-release d7fb291877 1.21.4
Automatically generated by python-semantic-release
2025-02-19 13:29:43 +00:00
wyzula_j ae18279685 fix(colors): pyqtgraph styling updated on the app level 2025-02-19 14:18:18 +01:00
wyzula_j 97c0ed53df fix(plot_base): mouse interactions default state fetch to toolbar 2025-02-19 14:18:18 +01:00
wyzula_j ff8e282034 refactor(plot_base): Change the PlotWidget to GraphicalLayoutWidget 2025-02-19 14:18:18 +01:00
semantic-release 440f36f289 1.21.3
Automatically generated by python-semantic-release
2025-02-19 12:44:37 +00:00
wyzula_j 0addef5f17 fix(bec_signal_proxy): unblock signal timer cleanup added 2025-02-19 13:33:16 +01:00
semantic-release 8c2a5e61fc 1.21.2
Automatically generated by python-semantic-release
2025-02-18 14:41:43 +00:00
wyzula_j 056731c9ad fix(client_utils): autoupdate has correct propagation of BECDockArea to plugin repos 2025-02-18 15:06:53 +01:00
semantic-release 911c81a167 1.21.1
Automatically generated by python-semantic-release
2025-02-17 14:54:21 +00:00
wyzula_j 8651314d93 build:unlock pyside version 2025-02-17 15:18:29 +01:00
wyzula_j 383936ffc2 fix(bec_connector): workers stored in reference to not be cleaned up with garbage collector 2025-02-17 15:18:29 +01:00
semantic-release 4378d33880 1.21.0
Automatically generated by python-semantic-release
2025-02-17 10:37:33 +00:00
perl_d 1708bd405f feat: generated form for scan metadata 2025-02-17 11:21:08 +01:00
wakonig_k 12811eccdb tests(scan_control): fixed hard-coded redis paths 2025-02-13 17:49:00 +01:00
semantic-release 5959fa87de 1.20.0
Automatically generated by python-semantic-release
2025-02-06 15:37:33 +00:00
perl_d b3217b7ca5 feat(widget): add LogPanel widget
hopefully without segfaults - compared to first implementation:
- explicitly set parent of all dialog components
- try/except and log for redis new message callback
- pass in ServiceStatusMixin and explicitly clean it up
2025-02-06 16:26:02 +01:00
semantic-release 35b941d054 1.19.2
Automatically generated by python-semantic-release
2025-02-06 15:23:58 +00:00
perl_d fc6d7c0824 fix: cleanup timer in Minesweeper 2025-02-06 15:12:48 +01:00
perl_d fb051865d5 fix: mock QTimer, improve timeout message 2025-02-06 15:12:48 +01:00
semantic-release 8aba3d975f 1.19.1
Automatically generated by python-semantic-release
2025-02-05 13:49:03 +00:00
wakonig_k 5e3289f5bd fix(macos): suppress IMKClient warning on macos 2025-02-05 13:01:40 +01:00
wakonig_k d07744397e Revert "feat(widget): add LogPanel widget"
This reverts commit f048880277
2025-02-05 08:57:09 +01:00
semantic-release dc7bf6b3c4 1.19.0
Automatically generated by python-semantic-release
2025-01-31 10:57:04 +00:00
perl_d f219c6fb57 docs: add docs for LogPanel 2025-01-31 10:10:08 +01:00
perl_d f048880277 feat(widget): add LogPanel widget 2025-01-31 10:10:08 +01:00
perl_d 50a572dacd fix: enable type checking for BECDispatcher in BECConnector 2025-01-30 17:28:30 +01:00
semantic-release b87549ba99 1.18.1
Automatically generated by python-semantic-release
2025-01-30 16:22:51 +00:00
appel_c f0c4efefa0 docs: add screenshots for device and signal input 2025-01-30 17:11:44 +01:00
appel_c db70442cc2 fix(signal_combo_box): added missing plugin modules for signal line_edit/combobox 2025-01-30 17:11:44 +01:00
semantic-release 07b8910686 1.18.0
Automatically generated by python-semantic-release
2025-01-30 16:07:01 +00:00
wyzula_j e7c97290cd feat(plot_base_next_gen): new type of plot base inherited from QWidget 2025-01-30 16:49:13 +01:00
wyzula_j 48fc63d83e fix(generate_cli): widgets can be tagged with RPC=False, then they are excluded from client.py for RPC 2025-01-30 16:49:13 +01:00
wyzula_j a20935e862 build: pyqt6 support dropped 2025-01-30 15:53:38 +01:00
wyzula_j 4f8e6835fe ci: fix formatter 2024 versions 2025-01-30 14:41:00 +01:00
semantic-release 042adfa51e 1.17.2
Automatically generated by python-semantic-release
2025-01-28 19:12:25 +00:00
wyzula_j b2b0450bcb fix(widget_state_manager): skip QLabel saving; skip_setting property widget excluded from INI; stored=False property excluded from INI 2025-01-28 18:34:21 +01:00
semantic-release 12e06fa971 1.17.1
Automatically generated by python-semantic-release
2025-01-26 15:32:17 +00:00
wyzula_j 6f2f2aa06a fix(bec_signal_proxy): timeout for blocking implemented 2025-01-26 14:29:30 +01:00
semantic-release 21965a0ee3 1.17.0
Automatically generated by python-semantic-release
2025-01-23 12:51:19 +00:00
perl_d 6df57103bb fix: focus policy and tab order for positioner_box_2d 2025-01-23 13:21:04 +01:00
perl_d 9a8cc31f6c docs: add documentation for 2D positioner box 2025-01-23 13:21:04 +01:00
perl_d d2ffddb6d8 feat(widget): add 2d positioner box widget 2025-01-23 13:21:04 +01:00
perl_d 3770db51be refactor: move positioner_box logic to base class 2025-01-23 13:21:04 +01:00
perl_d 2419521f5f refactor: move positioner_box and line into submodule
PositionerBox and PositionerControlLine are now exported from
from bec_widgets.widgets.control.device_control.positioner_box, removing
one level of hierarchy
2025-01-23 13:21:04 +01:00
semantic-release 80937cba97 1.16.5
Automatically generated by python-semantic-release
2025-01-22 19:12:06 +00:00
wyzula_j df961a9b88 fix(cli): server log level info and error 2025-01-22 20:02:00 +01:00
wyzula_j 219d43d325 fix(error_popups): errors in SafeProperty and in SafeSlot are always logged, even with error message popup enabled 2025-01-22 15:15:11 +01:00
semantic-release 229833eb99 1.16.4
Automatically generated by python-semantic-release
2025-01-21 16:29:14 +00:00
perl_d 141e1a34c9 fix: make combo box plugin files conform to autogen name 2025-01-20 15:24:05 +01:00
semantic-release d40075f85b 1.16.3
Automatically generated by python-semantic-release
2025-01-20 09:20:33 +00:00
wyzula_j dfa2908c3d test(error_popups): SafeSlot tests adjusted; tests extended to cover SafeProperty 2025-01-20 10:08:44 +01:00
wyzula_j 02a4862afd fix(error_popups): logger message in SafeSlot for errors; identification in error log from which property or signal errors comes from 2025-01-20 10:08:44 +01:00
semantic-release 13438e22d3 1.16.2
Automatically generated by python-semantic-release
2025-01-20 09:06:13 +00:00
wyzula_j 889ea8629f fix(widget_io): ToggleSwitchHandler added 2025-01-16 12:26:40 +01:00
semantic-release 0ef509e9ca 1.16.1
Automatically generated by python-semantic-release
2025-01-16 10:37:04 +00:00
wyzula_j b40d2c5f0b fix(error_popups): SafeProperty logger import fixed 2025-01-16 11:22:14 +01:00
semantic-release 6cd7ff6ef7 1.16.0
Automatically generated by python-semantic-release
2025-01-14 15:59:07 +00:00
wyzula_j 0fd5dd5a26 fix(e2e): num of elements to wait for scan fixed to steps requested in the scan 2025-01-14 16:47:57 +01:00
wyzula_j 508abfa8a5 fix(toolbar): adjusted to future plot base 2025-01-14 16:47:57 +01:00
wyzula_j 001e6fc807 feat(modular_toolbar): context menu and action bundles 2025-01-14 13:53:08 +01:00
semantic-release 111dcef35a 1.15.1
Automatically generated by python-semantic-release
2025-01-13 13:41:49 +00:00
wyzula_j 3b04b985b6 fix(error_popups): SafeProperty wrapper extended to catch more errors and not crash Designer 2025-01-13 11:25:25 +01:00
semantic-release 5944626d93 1.15.0
Automatically generated by python-semantic-release
2025-01-10 15:51:23 +00:00
wyzula_j a00d368c25 feat(widget_state_manager): example app added 2025-01-10 16:32:31 +01:00
wyzula_j 01b4608331 feat(widget_state_manager): state manager for single widget 2025-01-10 16:32:31 +01:00
semantic-release b7221d1151 1.14.1
Automatically generated by python-semantic-release
2025-01-10 14:34:09 +00:00
perl_d fa9ecaf433 fix: cast spinner widget angle to int when using for arc 2025-01-10 15:22:58 +01:00
semantic-release c751d25f85 1.14.0
Automatically generated by python-semantic-release
2025-01-09 14:29:40 +00:00
perl_d e2c7dc98d2 docs: add docs for games/minesweeper 2025-01-09 15:24:00 +01:00
perl_d 507d46f88b feat(widget): make Minesweeper into BEC widget 2025-01-09 15:24:00 +01:00
wakonig_k 57dc1a3afc feat(widgets): added minesweeper widget 2025-01-09 15:24:00 +01:00
semantic-release 6a78da0e71 1.13.0
Automatically generated by python-semantic-release
2025-01-09 14:18:04 +00:00
wakonig_k fb545eebb3 tests(safeslot): wait for panels to be properly rendered 2025-01-09 14:55:31 +01:00
wakonig_k b4a240e463 tests(e2e): wait for the plotting to finish before checking the data 2025-01-09 14:38:58 +01:00
wyzula_j 54e64c9f10 feat(widget_io): general change signal for supported widgets 2025-01-06 10:28:16 +01:00
wakonig_k 1c8b06cbe6 refactor(rpc,client_utils): minor cleanup and type hint improvements 2024-12-23 15:59:10 +01:00
guijar_m 52c5286d64 fix: do not display error popup if command is executed via RPC 2024-12-23 15:59:10 +01:00
guijar_m c405421db9 fix: use generator exec feature of BEC Connector to remove the AutoUpdate thread+queue 2024-12-23 15:59:10 +01:00
guijar_m 0ff0c06bd1 feat: add test for BECGuiClient features .new, .delete, .show, .hide, .close 2024-12-23 15:59:10 +01:00
guijar_m 955cc64257 fix: tests: rename fixtures and add 'connected_client_gui_obj' 2024-12-23 15:59:10 +01:00
guijar_m 09cb08a233 fix: prevent top-level dock areas to be destroyed with [X] button 2024-12-23 15:59:10 +01:00
guijar_m 5c83702382 refactor: move RPC-related classes and modules to 'rpc' directory
This allows to break circular import, too
2024-12-23 15:59:10 +01:00
guijar_m 1b0382524f fix: simplify AutoUpdate code thanks to threadpool executor in BEC Connector 2024-12-23 15:59:10 +01:00
guijar_m 92b802021f feat: add '.delete()' method to BECDockArea, make main window undeletable 2024-12-23 15:59:10 +01:00
guijar_m 48c140f937 fix: add .windows property to keep track of top level windows, ensure all windows are shown/hidden 2024-12-23 15:59:10 +01:00
guijar_m 42fd78df40 fix: remove useless class member 2024-12-23 15:59:10 +01:00
guijar_m 271a4a24e7 fix: determine default figure since the beginning 2024-12-23 15:59:10 +01:00
guijar_m 1b03ded906 fix: prevent infinite recursion in show/hide methods 2024-12-23 15:59:10 +01:00
guijar_m bde5618699 feat: add "new()" command to create new dock area windows from client 2024-12-23 15:59:10 +01:00
guijar_m 6f2eb6b4cd fix: bec-gui-server script: fix logic with __name__ == '__main__'
When started with "bec-gui-server" entry point, __name__ is
"bec_widgets.cli.server".
When started with "python -m bec_widgets.cli.server", __name__ is
"__main__".
So, better to not rely on __name__ at all.
2024-12-23 15:59:10 +01:00
guijar_m 2742a3c6cf fix: set minimum size hint on BECDockArea 2024-12-23 15:59:10 +01:00
guijar_m 809e654087 refactor: BECGuiClientMixin -> BECGuiClient
- Mixin class was only used with BECDockArea, now it is a class by itself
which represents the client object connected to the GUI server ; ".main"
is the dock area of the main window
- Enhanced "wait_for_server"
- ".selected_device" is stored in Redis, to allow server-side to know
about the auto update configuration instead of keeping it on client
2024-12-23 15:59:10 +01:00
guijar_m bdb25206d9 fix: use specified timeout in _run_rpc 2024-12-23 15:59:10 +01:00
wakonig_k bd5414288c build: fixed pytest bec dependency 2024-12-20 18:13:00 +01:00
wakonig_k 95f6a7ceb7 ci: install pytest plugin from specified repo, not pypi 2024-12-20 17:37:52 +01:00
semantic-release b75c4c88fe 1.12.0
Automatically generated by python-semantic-release
2024-12-12 10:35:17 +00:00
wyzula_j e38048964f feat(safe_property): added decorator to handle errors in Property decorator from qt to not crash designer 2024-12-11 22:37:03 +01:00
semantic-release ce11d1382c 1.11.0
Automatically generated by python-semantic-release
2024-12-11 16:19:34 +00:00
wyzula_j ff654b56ae test(collapsible_panel_manager): fixture changed to not use .show() 2024-12-11 15:24:59 +01:00
wyzula_j a434d3ee57 feat(collapsible_panel_manager): panel manager to handle collapsing and expanding widgets from the main widget added 2024-12-11 15:18:43 +01:00
semantic-release b467b29f77 1.10.0
Automatically generated by python-semantic-release
2024-12-10 19:59:55 +00:00
wyzula_j 17a63e3b63 feat(layout_manager): grid layout manager widget 2024-12-10 20:49:19 +01:00
semantic-release 66fc5306d6 1.9.1
Automatically generated by python-semantic-release
2024-12-10 19:34:00 +00:00
wyzula_j 6563abfddc fix(designer): general way to find python lib on linux 2024-12-10 19:12:21 +01:00
semantic-release 0d470ddf05 1.9.0
Automatically generated by python-semantic-release
2024-12-10 10:53:44 +00:00
wyzula_j 9b95b5d616 test(side_panel): tests added 2024-12-10 11:42:46 +01:00
wyzula_j c7d7c6d9ed feat(side_menu): side menu with stack widget added 2024-12-10 11:42:46 +01:00
semantic-release 4686a643f5 1.8.0
Automatically generated by python-semantic-release
2024-12-10 10:08:47 +00:00
wyzula_j 9370351abb test(modular_toolbar): tests added 2024-12-09 21:10:18 +01:00
wyzula_j a55134c3bf feat(modular_toolbar): material icons can be added/removed/hide/show/update dynamically 2024-12-09 20:56:03 +01:00
wyzula_j 5fdb2325ae feat(modular_toolbar): orientation setting 2024-12-09 15:04:59 +01:00
wyzula_j 6a36ca512d feat(round_frame): rounded frame for plot widgets and contrast adjustments 2024-12-09 15:01:09 +01:00
semantic-release a274a14900 1.7.0
Automatically generated by python-semantic-release
2024-12-02 15:21:52 +00:00
guijar_m da579b6d21 fix(tests): add test for Console widget 2024-12-02 14:44:29 +01:00
guijar_m 02086aeae0 feat(console): add 'terminate' and 'send_ctrl_c' methods to Console
.terminate() ends the started process, sending SIGTERM signal.
If process is not dead after optional timeout, SIGKILL is sent.
.send_ctrl_c() sends SIGINT to the child process, and waits for
prompt until optional timeout is reached.
Timeouts raise 'TimeoutError' exception.
2024-12-02 14:44:29 +01:00
guijar_m 3aeb0b66fb feat(console): add "prompt" signal to inform when shell is at prompt 2024-12-02 14:44:29 +01:00
semantic-release b4b8ae81d8 1.6.0
Automatically generated by python-semantic-release
2024-11-27 11:04:08 +00:00
guijar_m da18c2ceec fix(tests): make use of BECDockArea with client mixin to start server and use it in tests
Depending on the test, auto-updates are enabled or not.
2024-11-27 11:44:03 +01:00
guijar_m 31d87036c9 feat: '._auto_updates_enabled' attribute can be used to activate auto updates installation in BECDockArea 2024-11-27 11:44:03 +01:00
guijar_m cffcdf2923 fix: differentiate click and drag for DeviceItem, adapt tests accordingly
This fixes the blocking "QDrag.exec_()" on Linux, indeed before the
drag'n'drop operation was started with a simple click and it was
waiting for drop forever. Now there are 2 different cases, click or
drag'n'drop - the drag'n'drop test actually moves the mouse and releases
the button.
2024-11-27 11:44:03 +01:00
guijar_m 2fe7f5e151 fix(server): use dock area by default 2024-11-27 11:44:03 +01:00
wyzula_j 3ba0b1daf5 feat: add rpc_id member to client objects 2024-11-27 11:44:03 +01:00
guijar_m e68e2b5978 feat(client): add show()/hide() methods to "gui" object 2024-11-27 11:44:03 +01:00
guijar_m daf6ea0159 feat(server): add main window, with proper gui_id derived from given id 2024-11-27 11:44:03 +01:00
wyzula_j f80ec33ae5 feat: add main window container widget 2024-11-27 11:44:03 +01:00
wyzula_j c27d058b01 fix(rpc): gui hide/show also hide/show all floating docks 2024-11-27 11:44:03 +01:00
guijar_m 96e255e4ef fix: do not quit automatically when last window is "closed"
Qt confuses closed and hidden
2024-11-27 11:44:03 +01:00
guijar_m 60292465e9 fix: no need to call inspect.signature - it can fail on methods coming from C (like Qt methods) 2024-11-27 11:44:03 +01:00
guijar_m 2047e484d5 feat: asynchronous .start() for GUI 2024-11-27 11:44:03 +01:00
guijar_m 1f71d8e5ed feat: do not take focus when GUI is loaded 2024-11-25 08:16:10 +01:00
guijar_m 1f60fec720 feat: add '--hide' argument to BEC GUI server 2024-11-25 08:16:10 +01:00
wyzula_j e9983521ed fix: add back accidentally removed variables 2024-11-25 08:16:10 +01:00
semantic-release ed72393699 1.5.3
Automatically generated by python-semantic-release
2024-11-21 16:19:45 +00:00
wyzula_j e71e3b2956 fix(alignment_1d): fix imports after widget module refactor 2024-11-21 16:39:10 +01:00
appel_c 6e39bdbf53 ci: fix ci syntax for package-dep-job 2024-11-21 09:13:18 +01:00
semantic-release 2e7383a10c 1.5.2
Automatically generated by python-semantic-release
2024-11-18 13:53:35 +00:00
wakonig_k 746359b2cc fix: support for bec v3 2024-11-18 14:23:12 +01:00
semantic-release 0219f7c78a 1.5.1
Automatically generated by python-semantic-release
2024-11-14 13:30:02 +00:00
wyzula_j aab0229a40 refactor(widgets): widget module structure reorganised 2024-11-14 14:20:20 +01:00
wyzula_j 7a1b8748a4 fix(plugin_utils): plugin utils are able to detect classes for plugin creation based on class attribute rather than if it is top level widget 2024-11-14 14:19:22 +01:00
semantic-release 245ebb444e 1.5.0
Automatically generated by python-semantic-release
2024-11-12 15:29:42 +00:00
wyzula_j 0cd85ed9fa fix(crosshair): crosshair adapted for multi waveform widget 2024-11-12 16:19:42 +01:00
wyzula_j 42d4f182f7 docs(multi_waveform): docs added 2024-11-12 16:19:42 +01:00
wyzula_j f3a39a69e2 feat(multi-waveform): new widget added 2024-11-12 16:19:42 +01:00
439 changed files with 32674 additions and 5545 deletions
+20 -9
View File
@@ -12,6 +12,9 @@ variables:
description: ophyd_devices branch description: ophyd_devices branch
value: main value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH 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: workflow:
rules: rules:
@@ -31,8 +34,9 @@ include:
inputs: inputs:
stage: test stage: test
path: "." path: "."
pytest_args: "-v --random-order tests/" pytest_args: "-v,--random-order,tests/unit_tests"
exclude_packages: "" ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
# different stages in the pipeline # different stages in the pipeline
stages: stages:
@@ -57,6 +61,7 @@ stages:
- pip install -e ./ophyd_devices - pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev] - pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client - pip install -e ./bec/bec_ipython_client
- pip install -e ./bec/pytest_bec_e2e
.install-os-packages: &install-os-packages .install-os-packages: &install-os-packages
- apt-get update - apt-get update
@@ -73,9 +78,9 @@ formatter:
stage: Formatter stage: Formatter
needs: [] needs: []
script: script:
- pip install black isort - pip install bec_lib[dev]
- isort --check --diff ./ - isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color ./ - black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules: rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets" - if: $CI_PROJECT_PATH == "bec/bec_widgets"
@@ -143,7 +148,7 @@ tests:
- *clone-repos - *clone-repos
- *install-os-packages - *install-os-packages
- *install-repos - *install-repos
- pip install -e .[dev,pyqt6] - 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 run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report - coverage report
- coverage xml - coverage xml
@@ -167,7 +172,6 @@ test-matrix:
- "3.12" - "3.12"
QT_PCKG: QT_PCKG:
- "pyside6" - "pyside6"
- "pyqt6"
stage: AdditionalTests stage: AdditionalTests
needs: [] needs: []
@@ -193,7 +197,13 @@ end-2-end-conda:
script: script:
- *clone-repos - *clone-repos
- *install-os-packages - *install-os-packages
- conda config --prepend channels conda-forge - 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 channel_priority strict
- conda config --set always_yes yes --set changeps1 no - conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11 - conda create -q -n test-environment python=3.11
@@ -206,7 +216,7 @@ end-2-end-conda:
- cd ../ - cd ../
- pip install -e ./ophyd_devices - pip install -e ./ophyd_devices
- pip install -e .[dev,pyqt6] - pip install -e .[dev,pyside6]
- cd ./tests/end-2-end - cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order - pytest -v --start-servers --flush-redis --random-order
@@ -223,6 +233,7 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"' - if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"' - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"' - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
semver: semver:
stage: Deploy stage: Deploy
+6160 -16
View File
File diff suppressed because it is too large Load Diff
+15 -19
View File
@@ -1,12 +1,17 @@
# BEC Widgets # BEC Widgets
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec). BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
## Installation ## Installation
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets: Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash ```bash
pip install bec_widgets PyQt6 pip install bec_widgets[pyside6]
``` ```
For development purposes, you can clone the repository and install the package locally in editable mode: For development purposes, you can clone the repository and install the package locally in editable mode:
@@ -14,22 +19,12 @@ For development purposes, you can clone the repository and install the package l
```bash ```bash
git clone https://gitlab.psi.ch/bec/bec-widgets git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec_widgets cd bec_widgets
pip install -e .[dev,pyqt6] pip install -e .[dev,pyside6]
``` ```
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
Python Qt distributions manually. specified.
To select a specific Python Qt distribution, install the package with an additional tag:
```bash
pip install bec_widgets[pyqt6]
```
or
```bash
pip install bec_widgets[pyside6]
```
## Documentation ## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/). Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
@@ -39,7 +34,7 @@ Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs
All commits should use the Angular commit scheme: All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header > #### <a name="commit-header"></a>Angular Commit Message Header
> >
> ``` > ```
> <type>(<scope>): <short summary> > <type>(<scope>): <short summary>
> │ │ │ > │ │ │
@@ -53,13 +48,13 @@ All commits should use the Angular commit scheme:
> >
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test > └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ``` > ```
> >
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional. > The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type > ##### Type
> >
> Must be one of the following: > Must be one of the following:
> >
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) > * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs) > * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes > * **docs**: Documentation only changes
@@ -71,4 +66,5 @@ All commits should use the Angular commit scheme:
## License ## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/) [BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
@@ -5,41 +5,31 @@ It is a preliminary version of the GUI, which will be added to the main branch a
import os import os
from typing import Optional from typing import Optional
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import Signal as BECSignal from bec_lib.device import Signal as BECSignal
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Signal from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon from qtpy.QtGui import QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import QApplication
QApplication,
QCheckBox,
QDoubleSpinBox,
QMainWindow,
QPushButton,
QSpinBox,
)
import bec_widgets import bec_widgets
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.toolbar import WidgetAction
from bec_widgets.utils import UILoader from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
from bec_widgets.widgets.lmfit_dialog.lmfit_dialog import LMFitDialog PositionerGroup,
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox )
from bec_widgets.widgets.positioner_group.positioner_group import PositionerGroup from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.stop_button.stop_button import StopButton from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.toggle.toggle import ToggleSwitch from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger logger = bec_logger.logger
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
class Alignment1D: class Alignment1D:
"""Alignment GUI to perform 1D scans""" """Alignment GUI to perform 1D scans"""
+45 -61
View File
@@ -27,25 +27,17 @@ class AutoUpdates:
def __init__(self, gui: BECDockArea): def __init__(self, gui: BECDockArea):
self.gui = gui self.gui = gui
self.msg_queue = Queue() self._default_dock = None
self.auto_update_thread = None self._default_fig = None
self._shutdown_sentinel = object()
self.start()
def start(self):
"""
Start the auto update thread.
"""
self.auto_update_thread = threading.Thread(target=self.process_queue)
self.auto_update_thread.start()
def start_default_dock(self): def start_default_dock(self):
""" """
Create a default dock for the auto updates. Create a default dock for the auto updates.
""" """
dock = self.gui.add_dock("default_figure")
dock.add_widget("BECFigure")
self.dock_name = "default_figure" self.dock_name = "default_figure"
self._default_dock = self.gui.new(self.dock_name)
self._default_dock.new("BECFigure")
self._default_fig = self._default_dock.elements_list[0]
@staticmethod @staticmethod
def get_scan_info(msg) -> ScanInfo: def get_scan_info(msg) -> ScanInfo:
@@ -73,15 +65,9 @@ class AutoUpdates:
""" """
Get the default figure from the GUI. Get the default figure from the GUI.
""" """
dock = self.gui.panels.get(self.dock_name, []) return self._default_fig
if not dock:
return None
widgets = dock.widget_list
if not widgets:
return None
return widgets[0]
def run(self, msg): def do_update(self, msg):
""" """
Run the update function if enabled. Run the update function if enabled.
""" """
@@ -90,20 +76,9 @@ class AutoUpdates:
if msg.status != "open": if msg.status != "open":
return return
info = self.get_scan_info(msg) info = self.get_scan_info(msg)
self.handler(info) return self.handler(info)
def process_queue(self): def get_selected_device(self, monitored_devices, selected_device):
"""
Process the message queue.
"""
while True:
msg = self.msg_queue.get()
if msg is self._shutdown_sentinel:
break
self.run(msg)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
""" """
Get the selected device for the plot. If no device is selected, the first Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected. device in the monitored devices list is selected.
@@ -120,14 +95,11 @@ class AutoUpdates:
Default update function. Default update function.
""" """
if info.scan_name == "line_scan" and info.scan_report_devices: if info.scan_name == "line_scan" and info.scan_report_devices:
self.simple_line_scan(info) return self.simple_line_scan(info)
return
if info.scan_name == "grid_scan" and info.scan_report_devices: if info.scan_name == "grid_scan" and info.scan_report_devices:
self.simple_grid_scan(info) return self.simple_grid_scan(info)
return
if info.scan_report_devices: if info.scan_report_devices:
self.best_effort(info) return self.best_effort(info)
return
def simple_line_scan(self, info: ScanInfo) -> None: def simple_line_scan(self, info: ScanInfo) -> None:
""" """
@@ -137,12 +109,19 @@ class AutoUpdates:
if not fig: if not fig:
return return
dev_x = info.scan_report_devices[0] dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device) selected_device = yield self.gui.selected_device
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
if not dev_y: if not dev_y:
return return
fig.clear_all() yield fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}") yield fig.plot(
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y) x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
def simple_grid_scan(self, info: ScanInfo) -> None: def simple_grid_scan(self, info: ScanInfo) -> None:
""" """
@@ -153,12 +132,18 @@ class AutoUpdates:
return return
dev_x = info.scan_report_devices[0] dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1] dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device) selected_device = yield self.gui.selected_device
fig.clear_all() dev_z = self.get_selected_device(info.monitored_devices, selected_device)
plt = fig.plot( yield fig.clear_all()
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}" yield fig.plot(
x_name=dev_x,
y_name=dev_y,
z_name=dev_z,
label=f"Scan {info.scan_number} - {dev_z}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
) )
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None: def best_effort(self, info: ScanInfo) -> None:
""" """
@@ -168,17 +153,16 @@ class AutoUpdates:
if not fig: if not fig:
return return
dev_x = info.scan_report_devices[0] dev_x = info.scan_report_devices[0]
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device) selected_device = yield self.gui.selected_device
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
if not dev_y: if not dev_y:
return return
fig.clear_all() yield fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}") yield fig.plot(
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y) x_name=dev_x,
y_name=dev_y,
def shutdown(self): label=f"Scan {info.scan_number} - {dev_y}",
""" title=f"Scan {info.scan_number}",
Shutdown the auto update thread. x_label=dev_x,
""" y_label=dev_y,
self.msg_queue.put(self._shutdown_sentinel) )
if self.auto_update_thread:
self.auto_update_thread.join()
+2150 -678
View File
File diff suppressed because it is too large Load Diff
+339 -210
View File
@@ -1,3 +1,5 @@
"""Client utilities for the BEC GUI."""
from __future__ import annotations from __future__ import annotations
import importlib import importlib
@@ -7,59 +9,42 @@ import os
import select import select
import subprocess import subprocess
import threading import threading
import time from contextlib import contextmanager
import uuid
from functools import wraps
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_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 import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates from bec_widgets.cli.auto_updates import AutoUpdates
from bec_widgets.cli.rpc.rpc_base import RPCBase
if TYPE_CHECKING: if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase from bec_lib.device import DeviceBase
from bec_lib.redis_connector import StreamMessage
messages = lazy_import("bec_lib.messages") else:
# from bec_lib.connector import MessageObject messages = lazy_import("bec_lib.messages")
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",)) MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",)) StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
logger = bec_logger.logger logger = bec_logger.logger
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
def rpc_call(func):
def _filter_output(output: str) -> str:
""" """
A decorator for calling a function on the server. Filter out the output from the process.
Args:
func: The function to call.
Returns:
The result of the function call.
""" """
if "IMKClient" in output:
@wraps(func) # only relevant on macOS
def wrapper(self, *args, **kwargs): # see https://discussions.apple.com/thread/255761734?sortBy=rank
# we could rely on a strict type check here, but this is more flexible return ""
# moreover, it would anyway crash for objects... return output
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self.gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
def _get_output(process, logger) -> None: def _get_output(process, logger) -> None:
@@ -75,6 +60,7 @@ def _get_output(process, logger) -> None:
if stream in readylist: if stream in readylist:
buf.append(stream.read(4096)) buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n") output, _, remaining = "".join(buf).rpartition("\n")
output = _filter_output(output)
if output: if output:
log_func[stream](output) log_func[stream](output)
buf.clear() buf.clear()
@@ -83,7 +69,9 @@ def _get_output(process, logger) -> None:
logger.error(f"Error reading process output: {str(e)}") logger.error(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None: def _start_plot_process(
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
""" """
Start the plot in a new process. Start the plot in a new process.
@@ -92,11 +80,20 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
process will not be captured. process will not be captured.
""" """
# pylint: disable=subprocess-run-check # pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__] command = [
"bec-gui-server",
"--id",
gui_id,
"--gui_class",
gui_class.__name__,
"--gui_class_id",
gui_class_id,
"--hide",
]
if config: if config:
if isinstance(config, dict): if isinstance(config, dict):
config = json.dumps(config) config = json.dumps(config)
command.extend(["--config", config]) command.extend(["--config", str(config)])
env_dict = os.environ.copy() env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1" env_dict["PYTHONUNBUFFERED"] = "1"
@@ -126,14 +123,117 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
return process, process_output_processing_thread return process, process_output_processing_thread
class BECGuiClientMixin: class RepeatTimer(threading.Timer):
"""RepeatTimer class."""
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
# pylint: disable=protected-access
@contextmanager
def wait_for_server(client: BECGuiClient):
"""Context manager to wait for the server to start."""
timeout = client._startup_timeout
if not timeout:
if client._gui_is_alive():
# there is hope, let's wait a bit
timeout = 1
else:
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()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
# after initial waiting period, do not wait so much any more
# (only relevant if GUI didn't start)
client._startup_timeout = 0
yield
class WidgetNameSpace:
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr, value in self.__dict__.items():
docs = value.__doc__
docs = docs if docs else "No description available"
table.add_row(attr, docs)
console.print(table)
return f""
class AvailableWidgetsNamespace:
"""Namespace for available widgets in the BEC GUI."""
def __init__(self):
for widget in client.Widgets:
name = widget.value
if name in IGNORE_WIDGETS:
continue
setattr(self, name, name)
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr_name, _ in self.__dict__.items():
docs = getattr(client, attr_name).__doc__
docs = docs if docs else "No description available"
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
console.print(table)
return "" # f"<{self.__class__.__name__}>"
class BECDockArea(client.BECDockArea):
"""Extend the BECDockArea class and add namespaces to access widgets of docks."""
def __init__(self, gui_id=None, config=None, name=None, parent=None):
super().__init__(gui_id, config, name, parent)
# Add namespaces for DockArea
self.elements = WidgetNameSpace()
class BECGuiClient(RPCBase):
"""BEC GUI client class. Container for GUI applications within Python."""
_top_level: dict[str, BECDockArea] = {}
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self._default_dock_name = "bec"
self._auto_updates_enabled = True
self._auto_updates = None
self._killed = False
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None self._process = None
self._process_output_processing_thread = None self._process_output_processing_thread = None
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status() @property
self._selected_device = None def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return self._top_level
@property
def window_list(self) -> list:
"""List with dock areas in the GUI."""
return list(self._top_level.values())
# FIXME AUTO UPDATES
# @property
# def auto_updates(self):
# if self._auto_updates_enabled:
# with wait_for_server(self):
# return self._auto_updates
def _get_update_script(self) -> AutoUpdates | None: def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates") eps = imd.entry_points(group="bec.widgets.auto_updates")
@@ -144,197 +244,226 @@ class BECGuiClientMixin:
# if the module is not found, we skip it # if the module is not found, we skip it
if spec is None: if spec is None:
continue continue
return ep.load()(gui=self) return ep.load()(gui=self._top_level["main"])
except Exception as e: except Exception as e:
logger.error(f"Error loading auto update script from plugin: {str(e)}") logger.error(f"Error loading auto update script from plugin: {str(e)}")
return None return None
@property # FIXME AUTO UPDATES
def selected_device(self): # @property
""" # def selected_device(self) -> str | None:
Selected device for the plot. # """
""" # Selected device for the plot.
return self._selected_device # """
# auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
# auto_update_config = self._client.connector.get(auto_update_config_ep)
# if auto_update_config:
# return auto_update_config.selected_device
# return None
@selected_device.setter # @selected_device.setter
def selected_device(self, device: str | DeviceBase): # def selected_device(self, device: str | DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"): # if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._selected_device = device.name # self._client.connector.set_and_publish(
elif isinstance(device, str): # MessageEndpoints.gui_auto_update_config(self._gui_id),
self._selected_device = device # messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
else: # )
raise ValueError("Device must be a string or a device object") # elif isinstance(device, str):
# self._client.connector.set_and_publish(
# MessageEndpoints.gui_auto_update_config(self._gui_id),
# messages.GUIAutoUpdateConfigMessage(selected_device=device),
# )
# else:
# raise ValueError("Device must be a string or a device object")
def _start_update_script(self) -> None: # FIXME AUTO UPDATES
self._client.connector.register( # def _start_update_script(self) -> None:
self._target_endpoint, cb=self._handle_msg_update, parent=self # self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
# def _handle_msg_update(self, msg: StreamMessage) -> None:
# if self.auto_updates is not None:
# # pylint: disable=protected-access
# return self._update_script_msg_parser(msg.value)
# def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
# if isinstance(msg, messages.ScanStatusMessage):
# if not self._gui_is_alive():
# return
# if self._auto_updates_enabled:
# return self.auto_updates.do_update(msg)
def _gui_post_startup(self):
# if self._auto_updates_enabled:
# if self._auto_updates is None:
# auto_updates = self._get_update_script()
# if auto_updates is None:
# AutoUpdates.create_default_dock = True
# AutoUpdates.enabled = True
# auto_updates = AutoUpdates(self._top_level["main"].widget)
# if auto_updates.create_default_dock:
# auto_updates.start_default_dock()
# self._start_update_script()
# self._auto_updates = auto_updates
self._top_level[self._default_dock_name] = BECDockArea(
gui_id=f"{self._default_dock_name}", name=self._default_dock_name, parent=self
) )
self._do_show_all()
self._gui_started_event.set()
@staticmethod def _start_server(self, wait: bool = False) -> None:
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
if parent.auto_updates is not None:
# pylint: disable=protected-access
parent._update_script_msg_parser(msg.value)
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
self.auto_updates.msg_queue.put(msg)
def show(self) -> None:
""" """
Show the figure. Start the GUI server, and execute callback when it is launched
""" """
if self._process is None or self._process.poll() is not None: if self._process is None or self._process.poll() is not None:
self._start_update_script() logger.success("GUI starting...")
self._startup_timeout = 5
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process( self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config, logger=logger self._gui_id,
self.__class__,
gui_class_id=self._default_dock_name,
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
) )
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
logger.success(f"GUI started with id: {self._gui_id}")
def close(self) -> None: def gui_started_callback(callback):
try:
if callable(callback):
callback()
finally:
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
if wait:
self._gui_started_event.wait()
def _dump(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
return rpc_client._run_rpc("_dump")
def start(self, wait: bool = True) -> None:
"""Start the server and show the GUI window."""
return self._start_server(wait=wait)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
def _show_all(self):
with wait_for_server(self):
return self._do_show_all()
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
# because of the registry callbacks, we may have
# dock areas that are already killed, but not yet
# removed from the registry state
if not self._killed:
for window in self._top_level.values():
window.hide()
def show(self):
"""Show the GUI window."""
if self._process is not None:
return self._show_all()
# backward compatibility: show() was also starting server
return self._start_server(wait=True)
def hide(self):
"""Hide the GUI window."""
return self._hide_all()
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
) -> BECDockArea:
"""Create a new top-level dock area.
Args:
name(str, optional): The name of the dock area. Defaults to None.
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
Returns:
BECDockArea: The new dock area.
""" """
Close the gui window. if len(self.window_list) == 0:
self.show()
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
self._top_level[widget.widget_name] = widget
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
self._top_level[widget.widget_name] = widget
return widget
def delete(self, name: str) -> None:
"""Delete a dock area.
Args:
name(str): The name of the dock area.
""" """
widget = self.windows.get(name)
if widget is None:
raise ValueError(f"Dock area {name} not found.")
widget._run_rpc("close") # pylint: disable=protected-access
def delete_all(self) -> None:
"""Delete all dock areas."""
for widget_name in self.windows.keys():
self.delete(widget_name)
def close(self):
"""Deprecated. Use kill_server() instead."""
# FIXME, deprecated in favor of kill, will be removed in the future
self.kill_server()
def kill_server(self) -> None:
"""Kill the GUI server."""
self._top_level.clear()
self._killed = True
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
self._gui_started_timer.join()
if self._process is None: if self._process is None:
return return
self._client.shutdown()
if self._process: if self._process:
logger.success("Stopping GUI...")
self._process.terminate() self._process.terminate()
if self._process_output_processing_thread: if self._process_output_processing_thread:
self._process_output_processing_thread.join() self._process_output_processing_thread.join()
self._process.wait() self._process.wait()
self._process = None self._process = None
if self.auto_updates is not None:
self.auto_updates.shutdown()
class RPCResponseTimeoutError(Exception): if __name__ == "__main__": # pragma: no cover
"""Exception raised when an RPC response is not received within the expected time.""" from bec_lib.client import BECClient
from bec_lib.service_config import ServiceConfig
def __init__(self, request_id, timeout): config = ServiceConfig()
super().__init__( client = BECClient(config)
f"RPC response not received within {timeout} seconds for request ID {request_id}" client.start()
)
# Test the client_utils.py module
class RPCBase: gui = BECGuiClient()
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None: gui.start()
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance print(gui.window_list)
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
super().__init__()
# print(f"RPCBase: {self._gui_id}")
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} object at {hex(id(self))}>"
@property
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
"""
parent = self
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
"""
Run the RPC call.
Args:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if wait_for_rpc_response:
try:
finished = self._msg_wait_event.wait(10)
if not finished:
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
return None
if isinstance(msg_result, list):
return [self._create_widget_from_msg_result(res) for res in msg_result]
if isinstance(msg_result, dict):
if "__rpc__" not in msg_result:
return {
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
}
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)
if not cls:
return msg_result
cls = getattr(client, cls)
# print(msg_result)
return cls(parent=self, **msg_result)
return msg_result
def gui_is_alive(self):
"""
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
if heart is None:
return False
if heart.status == messages.BECStatus.RUNNING:
return True
return False
+31 -10
View File
@@ -11,7 +11,7 @@ import isort
from qtpy.QtCore import Property as QtProperty from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
from typing import get_overloads from typing import get_overloads
@@ -35,7 +35,7 @@ from __future__ import annotations
import enum import enum
from typing import Literal, Optional, overload from typing import Literal, Optional, overload
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
# pylint: skip-file""" # pylint: skip-file"""
@@ -43,14 +43,21 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
def generate_client(self, class_container: BECClassContainer): def generate_client(self, class_container: BECClassContainer):
""" """
Generate the client for the published classes. Generate the client for the published classes, skipping any classes
that have `RPC = False`.
Args: Args:
class_container: The class container with the classes to generate the client for. class_container: The class container with the classes to generate the client for.
""" """
rpc_top_level_classes = class_container.rpc_top_level_classes # Filter out classes that explicitly have RPC=False
rpc_top_level_classes = [
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
]
rpc_top_level_classes.sort(key=lambda x: x.__name__) rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = class_container.connector_classes
connector_classes = [
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
]
connector_classes.sort(key=lambda x: x.__name__) connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes) self.write_client_enum(rpc_top_level_classes)
@@ -81,16 +88,28 @@ class Widgets(str, enum.Enum):
class_name = cls.__name__ class_name = cls.__name__
# Generate the content if class_name == "BECDockArea":
if cls.__name__ == "BECDockArea":
self.content += f""" self.content += f"""
class {class_name}(RPCBase, BECGuiClientMixin):""" class {class_name}(RPCBase):"""
else: else:
self.content += f""" self.content += f"""
class {class_name}(RPCBase):""" class {class_name}(RPCBase):"""
if cls.__doc__:
# We only want the first line of the docstring
# But skip the first line if it's a blank line
first_line = cls.__doc__.split("\n")[0]
if first_line:
class_docs = first_line
else:
class_docs = cls.__doc__.split("\n")[1]
self.content += f"""
\"\"\"{class_docs}\"\"\"
"""
if not cls.USER_ACCESS: if not cls.USER_ACCESS:
self.content += """... self.content += """...
""" """
for method in cls.USER_ACCESS: for method in cls.USER_ACCESS:
is_property_setter = False is_property_setter = False
obj = getattr(cls, method, None) obj = getattr(cls, method, None)
@@ -100,8 +119,10 @@ class {class_name}(RPCBase):"""
method = method.split(".setter")[0] method = method.split(".setter")[0]
if obj is None: if obj is None:
raise AttributeError( raise AttributeError(
f"Method {method} not found in class {cls.__name__}. Please check the USER_ACCESS list." f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
) )
if isinstance(obj, (property, QtProperty)): if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties # for the cli, we can map qt properties to regular properties
if is_property_setter: if is_property_setter:
@@ -175,7 +196,7 @@ def main():
current_path = os.path.dirname(__file__) current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py") client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets") rpc_classes = get_custom_classes("bec_widgets")
generator = ClientGenerator() generator = ClientGenerator()
generator.generate_client(rpc_classes) generator.generate_client(rpc_classes)
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
import bec_widgets.cli.client as client
if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
else:
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
def rpc_call(func):
"""
A decorator for calling a function on the server.
Args:
func: The function to call.
Returns:
The result of the function call.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
class RPCResponseTimeoutError(Exception):
"""Exception raised when an RPC response is not received within the expected time."""
def __init__(self, request_id, timeout):
super().__init__(
f"RPC response not received within {timeout} seconds for request ID {request_id}"
)
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
name: str | None = None,
parent=None,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._name = name if name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
super().__init__()
# print(f"RPCBase: {self._gui_id}")
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.widget_name}>"
def remove(self):
"""
Remove the widget.
"""
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self._name
@property
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
"""
parent = self
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
"""
Run the RPC call.
Args:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if wait_for_rpc_response:
try:
finished = self._msg_wait_event.wait(timeout)
if not finished:
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
return None
if isinstance(msg_result, list):
return [self._create_widget_from_msg_result(res) for res in msg_result]
if isinstance(msg_result, dict):
if "__rpc__" not in msg_result:
return {
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
}
cls = msg_result.pop("widget_class", None)
msg_result.pop("__rpc__", None)
if not cls:
return msg_result
cls = getattr(client, cls)
# print(msg_result)
return cls(parent=self, **msg_result)
return msg_result
def _gui_is_alive(self):
"""
Check if the GUI is alive.
"""
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
if heart is None:
return False
if heart.status == messages.BECStatus.RUNNING:
return True
return False
@@ -1,8 +1,21 @@
from __future__ import annotations
from functools import wraps
from threading import Lock from threading import Lock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject from qtpy.QtCore import QObject
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
class RPCRegister: class RPCRegister:
""" """
@@ -47,7 +60,7 @@ class RPCRegister:
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.") raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None) self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject: def get_rpc_by_id(self, gui_id: str) -> QObject | None:
""" """
Get an RPC object by its ID. Get an RPC object by its ID.
@@ -55,7 +68,7 @@ class RPCRegister:
gui_id(str): The ID of the RPC object to be retrieved. gui_id(str): The ID of the RPC object to be retrieved.
Returns: Returns:
QObject: The RPC object with the given ID. QObject | None: The RPC object with the given ID or None
""" """
rpc_object = self._rpc_register.get(gui_id, None) rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object return rpc_object
@@ -71,6 +84,19 @@ class RPCRegister:
connections = dict(self._rpc_register) connections = dict(self._rpc_register)
return connections return connections
def get_names_of_rpc_by_class_type(
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
) -> list[str]:
"""Get all the names of the widgets.
Args:
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
"""
# This retrieves any rpc objects that are subclass of BECWidget,
# i.e. curve and image items are excluded
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
return [widget._name for widget in widgets]
@classmethod @classmethod
def reset_singleton(cls): def reset_singleton(cls):
""" """
@@ -1,4 +1,9 @@
from bec_widgets.utils import BECConnector from __future__ import annotations
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_widget import BECWidget
class RPCWidgetHandler: class RPCWidgetHandler:
@@ -8,7 +13,7 @@ class RPCWidgetHandler:
self._widget_classes = None self._widget_classes = None
@property @property
def widget_classes(self): def widget_classes(self) -> dict[str, type[BECWidget]]:
""" """
Get the available widget classes. Get the available widget classes.
@@ -17,7 +22,7 @@ class RPCWidgetHandler:
""" """
if self._widget_classes is None: if self._widget_classes is None:
self.update_available_widgets() self.update_available_widgets()
return self._widget_classes return self._widget_classes # type: ignore
def update_available_widgets(self): def update_available_widgets(self):
""" """
@@ -26,27 +31,28 @@ class RPCWidgetHandler:
Returns: Returns:
None None
""" """
from bec_widgets.utils.plugin_utils import get_rpc_classes from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_rpc_classes("bec_widgets") clss = get_custom_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes} self._widget_classes = {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, **kwargs) -> BECConnector: def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
""" """
Create a widget from an RPC message. Create a widget from an RPC message.
Args: Args:
widget_type(str): The type of the widget. widget_type(str): The type of the widget.
name (str): The name of the widget.
**kwargs: The keyword arguments for the widget. **kwargs: The keyword arguments for the widget.
Returns: Returns:
widget(BECConnector): The created widget. widget(BECWidget): The created widget.
""" """
if self._widget_classes is None: widget_class = self.widget_classes.get(widget_type) # type: ignore
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
if widget_class: if widget_class:
return widget_class(**kwargs) return widget_class(name=name, **kwargs)
raise ValueError(f"Unknown widget type: {widget_type}") raise ValueError(f"Unknown widget type: {widget_type}")
+114 -54
View File
@@ -1,29 +1,56 @@
from __future__ import annotations from __future__ import annotations
import inspect import functools
import json import json
import signal import signal
import sys import sys
from contextlib import redirect_stderr, redirect_stdout import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union from typing import Union
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
from bec_widgets.cli.rpc_register import RPCRegister from bec_widgets.cli.rpc import rpc_register
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.utils import BECDispatcher from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.dock.dock_area import BECDockArea from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.figure import BECFigure from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
messages = lazy_import("bec_lib.messages") messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger logger = bec_logger.logger
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class BECWidgetsCLIServer: class BECWidgetsCLIServer:
def __init__( def __init__(
@@ -32,16 +59,18 @@ class BECWidgetsCLIServer:
dispatcher: BECDispatcher = None, dispatcher: BECDispatcher = None,
client=None, client=None,
config=None, config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure, gui_class: Union[BECFigure, BECDockArea] = BECDockArea,
gui_class_id: str = "bec",
) -> None: ) -> None:
self.status = messages.BECStatus.BUSY self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client self.client = self.dispatcher.client if client is None else client
self.client.start() self.client.start()
self.gui_id = gui_id self.gui_id = gui_id
self.gui = gui_class(gui_id=self.gui_id) # register broadcast callback
self.rpc_register = RPCRegister() self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui) self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
# self.rpc_register.add_rpc(self.gui)
self.dispatcher.connect_slot( self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id) self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
@@ -54,22 +83,24 @@ class BECWidgetsCLIServer:
self.status = messages.BECStatus.RUNNING self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}") logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
def on_rpc_update(self, msg: dict, metadata: dict): def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id") request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}") logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
try: with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
obj = self.get_object_from_config(msg["parameter"]) try:
method = msg["action"] obj = self.get_object_from_config(msg["parameter"])
args = msg["parameter"].get("args", []) method = msg["action"]
kwargs = msg["parameter"].get("kwargs", {}) args = msg["parameter"].get("args", [])
res = self.run_rpc(obj, method, args, kwargs) kwargs = msg["parameter"].get("kwargs", {})
except Exception as e: res = self.run_rpc(obj, method, args, kwargs)
logger.error(f"Error while executing RPC instruction: {e}") except Exception as e:
self.send_response(request_id, False, {"error": str(e)}) logger.error(f"Error while executing RPC instruction: {e}")
else: self.send_response(request_id, False, {"error": str(e)})
logger.debug(f"RPC instruction executed successfully: {res}") else:
self.send_response(request_id, True, {"result": res}) logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict): def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish( self.client.connector.set_and_publish(
@@ -96,11 +127,7 @@ class BECWidgetsCLIServer:
setattr(obj, method, args[0]) setattr(obj, method, args[0])
res = None res = None
else: else:
sig = inspect.signature(method_obj) res = method_obj(*args, **kwargs)
if sig.parameters:
res = method_obj(*args, **kwargs)
else:
res = method_obj()
if isinstance(res, list): if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res] res = [self.serialize_object(obj) for obj in res]
@@ -114,6 +141,9 @@ class BECWidgetsCLIServer:
if isinstance(obj, BECConnector): if isinstance(obj, BECConnector):
return { return {
"gui_id": obj.gui_id, "gui_id": obj.gui_id,
"name": (
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
), # pylint: disable=protected-access
"widget_class": obj.__class__.__name__, "widget_class": obj.__class__.__name__,
"config": obj.config.model_dump(), "config": obj.config.model_dump(),
"__rpc__": True, "__rpc__": True,
@@ -122,11 +152,14 @@ class BECWidgetsCLIServer:
def emit_heartbeat(self): def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}") logger.trace(f"Emitting heartbeat for {self.gui_id}")
self.client.connector.set( try:
MessageEndpoints.gui_heartbeat(self.gui_id), self.client.connector.set(
messages.StatusMessage(name=self.gui_id, status=self.status, info={}), MessageEndpoints.gui_heartbeat(self.gui_id),
expire=10, messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
) expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
logger.info(f"Shutting down server with gui_id: {self.gui_id}") logger.info(f"Shutting down server with gui_id: {self.gui_id}")
@@ -155,7 +188,12 @@ class SimpleFileLikeFromLogOutputFunc:
return return
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None): def _start_server(
gui_id: str,
gui_class: Union[BECFigure, BECDockArea],
gui_class_id: str = "bec",
config: str | None = None,
):
if config: if config:
try: try:
config = json.loads(config) config = json.loads(config)
@@ -172,7 +210,9 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# service_name="BECWidgetsCLIServer", # service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config, # service_config=service_config.service_config,
# ) # )
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class) server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
)
return server return server
@@ -182,42 +222,52 @@ def main():
from qtpy.QtCore import QSize from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow from qtpy.QtWidgets import QApplication
import bec_widgets import bec_widgets
bec_logger.level = bec_logger.LOGLEVEL.DEBUG
if __name__ != "__main__":
# if not running as main, set the log level to critical
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.CRITICAL
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server") parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server") parser.add_argument("--id", type=str, default="test", help="The id of the server")
parser.add_argument( parser.add_argument(
"--gui_class", "--gui_class",
type=str, type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea", help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
) )
parser.add_argument(
"--gui_class_id",
type=str,
default="bec",
help="The id of the gui class that is added to the QApplication",
)
parser.add_argument("--config", type=str, help="Config file or config string.") parser.add_argument("--config", type=str, help="Config file or config string.")
parser.add_argument("--hide", action="store_true", help="Hide on startup")
args = parser.parse_args() args = parser.parse_args()
if args.gui_class == "BECFigure": bec_logger.level = bec_logger.LOGLEVEL.INFO
gui_class = BECFigure if args.hide:
elif args.gui_class == "BECDockArea": # pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
if args.gui_class == "BECDockArea":
gui_class = BECDockArea gui_class = BECDockArea
elif args.gui_class == "BECFigure":
gui_class = BECFigure
else: else:
print( print(
"Please specify a valid gui_class to run. Use -h for help." "Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure." "\n Starting with default gui_class BECFigure."
) )
gui_class = BECFigure gui_class = BECDockArea
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("BEC Figure") # set close on last window, only if not under control of client ;
# indeed, Qt considers a hidden window a closed window, so if all windows
# are hidden by default it exits
app.setQuitOnLastWindowClosed(not args.hide)
module_path = os.path.dirname(bec_widgets.__file__) module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon() icon = QIcon()
icon.addFile( icon.addFile(
@@ -225,22 +275,33 @@ def main():
size=QSize(48, 48), size=QSize(48, 48),
) )
app.setWindowIcon(icon) app.setWindowIcon(icon)
# store gui id within QApplication object, to make it available to all widgets
app.gui_id = args.id
win = QMainWindow() # args.id = "abff6"
win.setWindowTitle("BEC Widgets") server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
server = _start_server(args.id, gui_class, args.config) win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC")
RPCRegister().add_rpc(win)
gui = server.gui gui = server.gui
win.setCentralWidget(gui) win.setCentralWidget(gui)
win.resize(800, 600) if not args.hide:
win.show() win.show()
app.aboutToQuit.connect(server.shutdown) app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args): def sigint_handler(*args):
# display message, for people to let it terminate gracefully # display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting") print("Caught SIGINT, exiting")
# first hide all top level windows
# this is to discriminate the cases between "user clicks on [X]"
# (which should be filtered, to not close -see BECDockArea-)
# or "app is asked to close"
for window in app.topLevelWidgets():
window.hide() # so, we know we can exit because it is hidden
app.quit() app.quit()
signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGINT, sigint_handler)
@@ -249,6 +310,5 @@ def main():
sys.exit(app.exec()) sys.exit(app.exec())
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__":
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
main() main()
@@ -3,12 +3,11 @@ import os
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from bec_qthemes import material_icon from bec_qthemes import material_icon
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QPushButton,
QSplitter, QSplitter,
QTabWidget, QTabWidget,
QVBoxLayout, QVBoxLayout,
@@ -16,10 +15,15 @@ from qtpy.QtWidgets import (
) )
from bec_widgets.utils import BECDispatcher from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.dock.dock_area import BECDockArea from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.figure import BECFigure from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole 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_next_gen.image.image import Image
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover: class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -36,6 +40,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
{ {
"np": np, "np": np,
"pg": pg, "pg": pg,
"wh": wh,
"fig": self.figure, "fig": self.figure,
"dock": self.dock, "dock": self.dock,
"w1": self.w1, "w1": self.w1,
@@ -49,13 +54,22 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w9": self.w9, "w9": self.w9,
"w10": self.w10, "w10": self.w10,
"d0": self.d0, "d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
# "bar": self.bar,
# "cm": self.colormap,
"im": self.im, "im": self.im,
"mi": self.mi,
"mm": self.mm, "mm": self.mm,
"mw": self.mw,
"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,
} }
) )
@@ -80,11 +94,61 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
second_tab_layout.addWidget(self.figure) second_tab_layout.addWidget(self.figure)
tab_widget.addTab(second_tab, "BEC Figure") tab_widget.addTab(second_tab, "BEC Figure")
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 = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box) group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True) self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console) 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()
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(5)
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)
# add stuff to the new Waveform widget
self._init_waveform()
# add stuff to figure # add stuff to figure
self._init_figure() self._init_figure()
@@ -93,16 +157,15 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.setWindowTitle("Jupyter Console Window") self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def _init_figure(self): def _init_figure(self):
self.w1 = self.figure.plot( self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
x_name="samx",
y_name="bpm4i",
# title="Standard Plot with sync device, custom labels - w1",
# x_label="Motor Position",
# y_label="Intensity (A.U.)",
row=0,
col=0,
)
self.w1.set( self.w1.set(
title="Standard Plot with sync device, custom labels - w1", title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position", x_label="Motor Position",
@@ -158,24 +221,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def _init_dock(self): def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0") self.d0 = self.dock.new(name="dock_0")
self.mm = self.d0.add_widget("BECMotorMapWidget") self.mm = self.d0.new("BECMotorMapWidget")
self.mm.change_motors("samx", "samy") self.mm.change_motors("samx", "samy")
self.d1 = self.dock.add_dock(name="dock_1", position="right") self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("waveform", "1d")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
self.wf.plot(x_name="samx", y_name="bpm3a")
self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
# self.bar.set_diameter(200)
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
# self.colormap = pg.GradientWidget()
# self.d3.add_widget(self.colormap, row=0, col=0)
self.dock.save_state() self.dock.save_state()
@@ -200,8 +250,7 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console") app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console") app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark") icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
icon = material_icon("terminal", color="#434343", filled=True)
app.setWindowIcon(icon) app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher() bec_dispatcher = BECDispatcher()
@@ -210,6 +259,7 @@ if __name__ == "__main__": # pragma: no cover
win = JupyterConsoleWindow() win = JupyterConsoleWindow()
win.show() win.show()
win.resize(1500, 800)
app.aboutToQuit.connect(win.close) app.aboutToQuit.connect(win.close)
sys.exit(app.exec_()) sys.exit(app.exec_())
@@ -0,0 +1,380 @@
from __future__ import annotations
import sys
from typing import Literal
import pyqtgraph as pg
from qtpy.QtCore import Property, QEasingCurve, QObject, QPropertyAnimation
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QMainWindow,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from typeguard import typechecked
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
class DimensionAnimator(QObject):
"""
Helper class to animate the size of a panel widget.
"""
def __init__(self, panel_widget: QWidget, direction: str):
super().__init__()
self.panel_widget = panel_widget
self.direction = direction
self._size = 0
@Property(int)
def panel_width(self):
"""
Returns the current width of the panel widget.
"""
return self._size
@panel_width.setter
def panel_width(self, val: int):
"""
Set the width of the panel widget.
Args:
val(int): The width to set.
"""
self._size = val
self.panel_widget.setFixedWidth(val)
@Property(int)
def panel_height(self):
"""
Returns the current height of the panel widget.
"""
return self._size
@panel_height.setter
def panel_height(self, val: int):
"""
Set the height of the panel widget.
Args:
val(int): The height to set.
"""
self._size = val
self.panel_widget.setFixedHeight(val)
class CollapsiblePanelManager(QObject):
"""
Manager class to handle collapsible panels from a main widget using LayoutManagerWidget.
"""
def __init__(self, layout_manager: LayoutManagerWidget, reference_widget: QWidget, parent=None):
super().__init__(parent)
self.layout_manager = layout_manager
self.reference_widget = reference_widget
self.animations = {}
self.panels = {}
self.direction_settings = {
"left": {"property": b"maximumWidth", "default_size": 200},
"right": {"property": b"maximumWidth", "default_size": 200},
"top": {"property": b"maximumHeight", "default_size": 150},
"bottom": {"property": b"maximumHeight", "default_size": 150},
}
def add_panel(
self,
direction: Literal["left", "right", "top", "bottom"],
panel_widget: QWidget,
target_size: int | None = None,
duration: int = 300,
):
"""
Add a panel widget to the layout manager.
Args:
direction(Literal["left", "right", "top", "bottom"]): Direction of the panel.
panel_widget(QWidget): The panel widget to add.
target_size(int, optional): The target size of the panel. Defaults to None.
duration(int): The duration of the animation in milliseconds. Defaults to 300.
"""
if direction not in self.direction_settings:
raise ValueError("Direction must be one of 'left', 'right', 'top', 'bottom'.")
if target_size is None:
target_size = self.direction_settings[direction]["default_size"]
self.layout_manager.add_widget_relative(
widget=panel_widget, reference_widget=self.reference_widget, position=direction
)
panel_widget.setVisible(False)
# Set initial constraints as flexible
if direction in ["left", "right"]:
panel_widget.setMaximumWidth(0)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
panel_widget.setMaximumHeight(0)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.panels[direction] = {
"widget": panel_widget,
"direction": direction,
"target_size": target_size,
"duration": duration,
"animator": None,
}
def toggle_panel(
self,
direction: Literal["left", "right", "top", "bottom"],
target_size: int | None = None,
duration: int | None = None,
easing_curve: QEasingCurve = QEasingCurve.InOutQuad,
ensure_max: bool = False,
scale: float | None = None,
animation: bool = True,
):
"""
Toggle the specified panel.
Parameters:
direction (Literal["left", "right", "top", "bottom"]): Direction of the panel to toggle.
target_size (int, optional): Override target size for this toggle.
duration (int, optional): Override the animation duration.
easing_curve (QEasingCurve): Animation easing curve.
ensure_max (bool): If True, animate as a fixed-size panel.
scale (float, optional): If provided, calculate target_size from main widget size.
animation (bool): If False, no animation is performed; panel instantly toggles.
"""
if direction not in self.panels:
raise ValueError(f"No panel found in direction '{direction}'.")
panel_info = self.panels[direction]
panel_widget = panel_info["widget"]
dir_settings = self.direction_settings[direction]
# Determine final target size
if scale is not None:
main_rect = self.reference_widget.geometry()
if direction in ["left", "right"]:
computed_target = int(main_rect.width() * scale)
else:
computed_target = int(main_rect.height() * scale)
final_target_size = computed_target
else:
if target_size is None:
final_target_size = panel_info["target_size"]
else:
final_target_size = target_size
if duration is None:
duration = panel_info["duration"]
expanding_property = dir_settings["property"]
currently_visible = panel_widget.isVisible()
if ensure_max:
if panel_info["animator"] is None:
panel_info["animator"] = DimensionAnimator(panel_widget, direction)
animator = panel_info["animator"]
if direction in ["left", "right"]:
prop_name = b"panel_width"
else:
prop_name = b"panel_height"
else:
animator = None
prop_name = expanding_property
if currently_visible:
# Hide the panel
if ensure_max:
start_value = final_target_size
end_value = 0
finish_callback = lambda w=panel_widget, d=direction: self._after_hide_reset(w, d)
else:
start_value = (
panel_widget.width()
if direction in ["left", "right"]
else panel_widget.height()
)
end_value = 0
finish_callback = lambda w=panel_widget: w.setVisible(False)
else:
# Show the panel
start_value = 0
end_value = final_target_size
finish_callback = None
if ensure_max:
# Fix panel exactly
if direction in ["left", "right"]:
panel_widget.setMinimumWidth(0)
panel_widget.setMaximumWidth(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
else:
panel_widget.setMinimumHeight(0)
panel_widget.setMaximumHeight(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
else:
# Flexible mode
if direction in ["left", "right"]:
panel_widget.setMinimumWidth(0)
panel_widget.setMaximumWidth(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
panel_widget.setMinimumHeight(0)
panel_widget.setMaximumHeight(final_target_size)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
panel_widget.setVisible(True)
if not animation:
# No animation: instantly set final state
if end_value == 0:
# Hiding
if ensure_max:
# Reset after hide
self._after_hide_reset(panel_widget, direction)
else:
panel_widget.setVisible(False)
else:
# Showing
if ensure_max:
# Already set fixed size
if direction in ["left", "right"]:
panel_widget.setFixedWidth(end_value)
else:
panel_widget.setFixedHeight(end_value)
else:
# Just set maximum dimension
if direction in ["left", "right"]:
panel_widget.setMaximumWidth(end_value)
else:
panel_widget.setMaximumHeight(end_value)
return
# With animation
animation = QPropertyAnimation(animator if ensure_max else panel_widget, prop_name)
animation.setDuration(duration)
animation.setStartValue(start_value)
animation.setEndValue(end_value)
animation.setEasingCurve(easing_curve)
if end_value == 0 and finish_callback:
animation.finished.connect(finish_callback)
elif end_value == 0 and not finish_callback:
animation.finished.connect(lambda w=panel_widget: w.setVisible(False))
animation.start()
self.animations[panel_widget] = animation
@typechecked
def _after_hide_reset(
self, panel_widget: QWidget, direction: Literal["left", "right", "top", "bottom"]
):
"""
Reset the panel widget after hiding it in ensure_max mode.
Args:
panel_widget(QWidget): The panel widget to reset.
direction(Literal["left", "right", "top", "bottom"]): The direction of the panel.
"""
# Called after hiding a panel in ensure_max mode
panel_widget.setVisible(False)
if direction in ["left", "right"]:
panel_widget.setMinimumWidth(0)
panel_widget.setMaximumWidth(0)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
else:
panel_widget.setMinimumHeight(0)
panel_widget.setMaximumHeight(16777215)
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
####################################################################################################
# The following code is for the GUI control panel to interact with the CollapsiblePanelManager.
# It is not covered by any tests as it serves only as an example for the CollapsiblePanelManager class.
####################################################################################################
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Panels with ensure_max, scale, and animation toggle")
self.resize(800, 600)
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# Buttons
buttons_layout = QHBoxLayout()
self.btn_left = QPushButton("Toggle Left (ensure_max=True)")
self.btn_top = QPushButton("Toggle Top (scale=0.5, no animation)")
self.btn_right = QPushButton("Toggle Right (ensure_max=True, scale=0.3)")
self.btn_bottom = QPushButton("Toggle Bottom (no animation)")
buttons_layout.addWidget(self.btn_left)
buttons_layout.addWidget(self.btn_top)
buttons_layout.addWidget(self.btn_right)
buttons_layout.addWidget(self.btn_bottom)
main_layout.addLayout(buttons_layout)
self.layout_manager = LayoutManagerWidget()
main_layout.addWidget(self.layout_manager)
# Main widget
self.main_plot = pg.PlotWidget()
self.main_plot.plot([1, 2, 3, 4], [4, 3, 2, 1])
self.layout_manager.add_widget(self.main_plot, 0, 0)
self.panel_manager = CollapsiblePanelManager(self.layout_manager, self.main_plot)
# Panels
self.left_panel = pg.PlotWidget()
self.left_panel.plot([1, 2, 3], [3, 2, 1])
self.panel_manager.add_panel("left", self.left_panel, target_size=200)
self.right_panel = pg.PlotWidget()
self.right_panel.plot([10, 20, 30], [1, 10, 1])
self.panel_manager.add_panel("right", self.right_panel, target_size=200)
self.top_panel = pg.PlotWidget()
self.top_panel.plot([1, 2, 3], [1, 2, 3])
self.panel_manager.add_panel("top", self.top_panel, target_size=150)
self.bottom_panel = pg.PlotWidget()
self.bottom_panel.plot([2, 4, 6], [10, 5, 10])
self.panel_manager.add_panel("bottom", self.bottom_panel, target_size=150)
# Connect buttons
# Left with ensure_max
self.btn_left.clicked.connect(
lambda: self.panel_manager.toggle_panel("left", ensure_max=True)
)
# Top with scale=0.5 and no animation
self.btn_top.clicked.connect(
lambda: self.panel_manager.toggle_panel("top", scale=0.5, animation=False)
)
# Right with ensure_max, scale=0.3
self.btn_right.clicked.connect(
lambda: self.panel_manager.toggle_panel("right", ensure_max=True, scale=0.3)
)
# Bottom no animation
self.btn_bottom.clicked.connect(
lambda: self.panel_manager.toggle_panel("bottom", target_size=100, animation=False)
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())
+102 -4
View File
@@ -2,9 +2,93 @@ import functools
import sys import sys
import traceback import traceback
from qtpy.QtCore import QObject, Qt, Signal, Slot from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
logger = bec_logger.logger
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
"""
Decorator to create a Qt Property with safe getter and setter so that
Qt Designer won't crash if an exception occurs in either method.
Args:
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
default: Any default/fallback value to return if the getter raises an exception.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
Usage:
@SafeProperty(int, default=-1)
def some_value(self) -> int:
# your getter logic
return ... # if an exception is raised, returns -1
@some_value.setter
def some_value(self, val: int):
# your setter logic
...
"""
def decorator(py_getter):
"""Decorator for the user's property getter function."""
@functools.wraps(py_getter)
def safe_getter(self_):
try:
return py_getter(self_)
except Exception:
# Identify which property function triggered error
prop_name = f"{py_getter.__module__}.{py_getter.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
return default
class PropertyWrapper:
"""
Intermediate wrapper used so that the user can optionally chain .setter(...).
"""
def __init__(self, getter_func):
# We store only our safe_getter in the wrapper
self.getter_func = safe_getter
def setter(self, setter_func):
"""Wraps the user-defined setter to handle errors safely."""
@functools.wraps(setter_func)
def safe_setter(self_, value):
try:
return setter_func(self_, value)
except Exception:
prop_name = f"{setter_func.__module__}.{setter_func.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=True
)
logger.error(f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}")
return
# Return the full read/write Property
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
def __call__(self):
"""
If user never calls `.setter(...)`, produce a read-only property.
"""
return Property(prop_type, self.getter_func, None, *prop_args, **prop_kwargs)
return PropertyWrapper(py_getter)
return decorator
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot """Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
@@ -22,7 +106,13 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
try: try:
return method(*args, **kwargs) return method(*args, **kwargs)
except Exception: except Exception:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error) slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
return wrapper return wrapper
@@ -91,6 +181,12 @@ class _ErrorPopupUtility(QObject):
msg.setMinimumHeight(400) msg.setMinimumHeight(400)
msg.exec_() msg.exec_()
def show_property_error(self, title, message, widget):
"""
Show a property-specific error message.
"""
self.error_occurred.emit(title, message, widget)
def format_traceback(self, traceback_message: str) -> str: def format_traceback(self, traceback_message: str) -> str:
""" """
Format the traceback message to be displayed in the error popup by adding indentation to each line. Format the traceback message to be displayed in the error popup by adding indentation to each line.
@@ -127,12 +223,14 @@ class _ErrorPopupUtility(QObject):
error_message = " ".join(captured_message) error_message = " ".join(captured_message)
return error_message return error_message
def get_error_message(self, exctype, value, tb):
return "".join(traceback.format_exception(exctype, value, tb))
def custom_exception_hook(self, exctype, value, tb, popup_error=False): def custom_exception_hook(self, exctype, value, tb, popup_error=False):
if popup_error or self.enable_error_popup: if popup_error or self.enable_error_popup:
error_message = traceback.format_exception(exctype, value, tb)
self.error_occurred.emit( self.error_occurred.emit(
"Method error" if popup_error else "Application Error", "Method error" if popup_error else "Application Error",
"".join(error_message), self.get_error_message(exctype, value, tb),
self.parent(), self.parent(),
) )
else: else:
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLayout,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
super().__init__(parent=parent)
self._expanded = expanded
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget()
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_icon()
@SafeProperty(bool)
def expanded(self): # type: ignore
return self._expanded
@expanded.setter
def expanded(self, expanded: bool):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
def _update_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
else material_icon(
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)
+1 -1
View File
@@ -13,7 +13,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class PaletteViewer(BECWidget, QWidget): class PaletteViewer(BECWidget, QWidget):
+157
View File
@@ -0,0 +1,157 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(BECWidget, QFrame):
"""
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.
"""
def __init__(
self,
parent=None,
content_widget: QWidget = None,
background_color: str = None,
theme_update: bool = True,
radius: int = 10,
**kwargs,
):
super().__init__(**kwargs)
QFrame.__init__(self, parent)
self.background_color = background_color
self.theme_update = theme_update if background_color is None else False
self._radius = radius
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Create a layout for the frame
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
self.layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self._connect_to_theme_change()
def apply_theme(self, theme: str):
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if not self.theme_update:
return
# 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
self.update_style()
@Property(int)
def radius(self):
"""Radius of the rounded corners."""
return self._radius
@radius.setter
def radius(self, value: int):
self._radius = value
self.update_style()
def update_style(self):
"""
Update the style of the frame based on the background color.
"""
if self.background_color:
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
background-color: {self.background_color};
border-radius: {self._radius}; /* Rounded corners */
}}
"""
)
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').
"""
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)
class ExampleApp(QWidget): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Rounded Plots Example")
# Main layout
layout = QVBoxLayout(self)
dark_button = DarkModeButton()
# Create PlotWidgets
plot1 = pg.GraphicsLayoutWidget()
plot_item_1 = pg.PlotItem()
plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1.plot_item = plot_item_1
plot2 = pg.GraphicsLayoutWidget()
plot_item_2 = pg.PlotItem()
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(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_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)
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
window = ExampleApp()
window.show()
app.exec()
+7 -6
View File
@@ -1,6 +1,6 @@
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot from bec_widgets.qt_utils.error_popups import SafeSlot
class SettingWidget(QWidget): class SettingWidget(QWidget):
@@ -20,14 +20,14 @@ class SettingWidget(QWidget):
def set_target_widget(self, target_widget: QWidget): def set_target_widget(self, target_widget: QWidget):
self.target_widget = target_widget self.target_widget = target_widget
@Slot() @SafeSlot()
def accept_changes(self): def accept_changes(self):
""" """
Accepts the changes made in the settings widget and applies them to the target widget. Accepts the changes made in the settings widget and applies them to the target widget.
""" """
pass pass
@Slot(dict) @SafeSlot(dict)
def display_current_settings(self, config_dict: dict): def display_current_settings(self, config_dict: dict):
""" """
Displays the current settings of the target widget in the settings widget. Displays the current settings of the target widget in the settings widget.
@@ -54,12 +54,13 @@ class SettingsDialog(QDialog):
settings_widget: SettingWidget = None, settings_widget: SettingWidget = None,
window_title: str = "Settings", window_title: str = "Settings",
config: dict = None, config: dict = None,
modal: bool = False,
*args, *args,
**kwargs, **kwargs,
): ):
super().__init__(parent, *args, **kwargs) super().__init__(parent, *args, **kwargs)
self.setModal(False) self.setModal(modal)
self.setWindowTitle(window_title) self.setWindowTitle(window_title)
@@ -92,7 +93,7 @@ class SettingsDialog(QDialog):
ok_button.setDefault(True) ok_button.setDefault(True)
ok_button.setAutoDefault(True) ok_button.setAutoDefault(True)
@Slot() @SafeSlot()
def accept(self): def accept(self):
""" """
Accept the changes made in the settings widget and close the dialog. Accept the changes made in the settings widget and close the dialog.
@@ -100,7 +101,7 @@ class SettingsDialog(QDialog):
self.widget.accept_changes() self.widget.accept_changes()
super().accept() super().accept()
@Slot() @SafeSlot()
def apply_changes(self): def apply_changes(self):
""" """
Apply the changes made in the settings widget without closing the dialog. Apply the changes made in the settings widget without closing the dialog.
+369
View File
@@ -0,0 +1,369 @@
import sys
from typing import Literal, Optional
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation
from qtpy.QtGui import QAction
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QScrollArea,
QSizePolicy,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
class SidePanel(QWidget):
"""
Side panel widget that can be placed on the left, right, top, or bottom of the main widget.
"""
def __init__(
self,
parent=None,
orientation: Literal["left", "right", "top", "bottom"] = "left",
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("SidePanel")
self._orientation = orientation
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._panel_width = 0
self._panel_height = 0
self.panel_visible = False
self.current_action: Optional[QAction] = None
self.current_index: Optional[int] = None
self.switching_actions = False
self._init_ui()
def _init_ui(self):
"""
Initialize the UI elements.
"""
if self._orientation in ("left", "right"):
self.main_layout = QHBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
self.container.layout.setContentsMargins(0, 0, 0, 0)
self.container.layout.setSpacing(0)
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.panel_width = 0 # start hidden
else:
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
self.container.layout.setContentsMargins(0, 0, 0, 0)
self.container.layout.setSpacing(0)
self.stack_widget = QStackedWidget()
self.stack_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.stack_widget.setMinimumHeight(5)
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_height")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.panel_height = 0 # start hidden
self.menu_anim.setDuration(self._animation_duration)
self.menu_anim.setEasingCurve(QEasingCurve.InOutQuad)
@Property(int)
def panel_width(self):
"""Get the panel width."""
return self._panel_width
@panel_width.setter
def panel_width(self, width: int):
"""Set the panel width."""
self._panel_width = width
if self._orientation in ("left", "right"):
self.stack_widget.setFixedWidth(width)
@Property(int)
def panel_height(self):
"""Get the panel height."""
return self._panel_height
@panel_height.setter
def panel_height(self, height: int):
"""Set the panel height."""
self._panel_height = height
if self._orientation in ("top", "bottom"):
self.stack_widget.setFixedHeight(height)
@Property(int)
def panel_max_width(self):
"""Get the maximum width of the panel."""
return self._panel_max_width
@panel_max_width.setter
def panel_max_width(self, size: int):
"""Set the maximum width of the panel."""
self._panel_max_width = size
if self._orientation in ("left", "right"):
self.stack_widget.setMaximumWidth(self._panel_max_width)
else:
self.stack_widget.setMaximumHeight(self._panel_max_width)
@Property(int)
def animation_duration(self):
"""Get the duration of the animation."""
return self._animation_duration
@animation_duration.setter
def animation_duration(self, duration: int):
"""Set the duration of the animation."""
self._animation_duration = duration
self.menu_anim.setDuration(duration)
@Property(bool)
def animations_enabled(self):
"""Get the status of the animations."""
return self._animations_enabled
@animations_enabled.setter
def animations_enabled(self, enabled: bool):
"""Set the status of the animations."""
self._animations_enabled = enabled
def show_panel(self, idx: int):
"""
Show the side panel with animation and switch to idx.
"""
self.stack_widget.setCurrentIndex(idx)
self.panel_visible = True
self.current_index = idx
if self._orientation in ("left", "right"):
start_val, end_val = 0, self._panel_max_width
else:
start_val, end_val = 0, self._panel_max_width
if self._animations_enabled:
self.menu_anim.stop()
self.menu_anim.setStartValue(start_val)
self.menu_anim.setEndValue(end_val)
self.menu_anim.start()
else:
if self._orientation in ("left", "right"):
self.panel_width = end_val
else:
self.panel_height = end_val
def hide_panel(self):
"""
Hide the side panel with animation.
"""
self.panel_visible = False
self.current_index = None
if self._orientation in ("left", "right"):
start_val, end_val = self._panel_max_width, 0
else:
start_val, end_val = self._panel_max_width, 0
if self._animations_enabled:
self.menu_anim.stop()
self.menu_anim.setStartValue(start_val)
self.menu_anim.setEndValue(end_val)
self.menu_anim.start()
else:
if self._orientation in ("left", "right"):
self.panel_width = end_val
else:
self.panel_height = end_val
def switch_to(self, idx: int):
"""
Switch to the specified index without animation.
"""
if self.current_index != idx:
self.stack_widget.setCurrentIndex(idx)
self.current_index = idx
def add_menu(self, action_id: str, icon_name: str, tooltip: str, widget: QWidget, title: str):
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
title(str): The title of the panel.
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
container_layout = QVBoxLayout(container_widget)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(5)
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
container_layout.addWidget(title_label)
# Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
scroll_area = QScrollArea()
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setWidgetResizable(True)
# Let the scroll area expand in both directions if there's room
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
scroll_area.setWidget(widget)
# Put the scroll area in the container layout
container_layout.addWidget(scroll_area)
# Optionally stretch the scroll area to fill vertical space
container_layout.setStretchFactor(scroll_area, 1)
# Add container_widget to the stacked widget
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
############################################
# DEMO APPLICATION
############################################
class ExampleApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Side Panel Example")
central_widget = QWidget()
self.setCentralWidget(central_widget)
self.layout = QHBoxLayout(central_widget)
# Create side panel
self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
self.layout.addWidget(self.side_panel)
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
self.plot = Waveform()
self.layout.addWidget(self.plot)
self.add_side_menus()
def add_side_menus(self):
widget1 = QWidget()
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = ExampleApp()
window.resize(1000, 700)
window.show()
sys.exit(app.exec())
+763 -59
View File
@@ -2,28 +2,70 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from typing import Literal from typing import Dict, List, Literal, Tuple
from bec_qthemes._icon.material_icons import material_icon from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMainWindow,
QMenu, QMenu,
QSizePolicy, QSizePolicy,
QStyle,
QToolBar, QToolBar,
QToolButton, QToolButton,
QVBoxLayout,
QWidget, QWidget,
) )
import bec_widgets import bec_widgets
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
MODULE_PATH = os.path.dirname(bec_widgets.__file__) MODULE_PATH = os.path.dirname(bec_widgets.__file__)
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
class LongPressToolButton(QToolButton):
def __init__(self, *args, long_press_threshold=500, **kwargs):
super().__init__(*args, **kwargs)
self.long_press_threshold = long_press_threshold
self._long_press_timer = QTimer(self)
self._long_press_timer.setSingleShot(True)
self._long_press_timer.timeout.connect(self.handleLongPress)
self._pressed = False
self._longPressed = False
def mousePressEvent(self, event):
self._pressed = True
self._longPressed = False
self._long_press_timer.start(self.long_press_threshold)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
self._pressed = False
if self._longPressed:
self._longPressed = False
self._long_press_timer.stop()
event.accept() # Prevent normal click action after a long press
return
self._long_press_timer.stop()
super().mouseReleaseEvent(event)
def handleLongPress(self):
if self._pressed:
self._longPressed = True
self.showMenu()
class ToolBarAction(ABC): class ToolBarAction(ABC):
""" """
@@ -31,7 +73,7 @@ class ToolBarAction(ABC):
Args: Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None. icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None. tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False. checkable (bool, optional): Whether the action is checkable. Defaults to False.
""" """
@@ -81,15 +123,33 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action) toolbar.addAction(self.action)
class MaterialIconAction: class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(self.icon, self.tooltip, parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
""" """
Action with a Material icon for the toolbar. Action with a Material icon for the toolbar.
Args: Args:
icon_path (str, optional): The name of the Material icon. Defaults to None. icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, optional): The tooltip for the action. Defaults to None. tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False. checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. 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.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
""" """
def __init__( def __init__(
@@ -99,30 +159,41 @@ class MaterialIconAction:
checkable: bool = False, checkable: bool = False,
filled: bool = False, filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
parent=None,
): ):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name self.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled self.filled = filled
self.color = color self.color = color
# Generate the icon using the material_icon helper
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): self.icon = material_icon(
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
self.icon_name, self.icon_name,
size=(20, 20), size=(20, 20),
convert_to_pixmap=False, convert_to_pixmap=False,
filled=self.filled, filled=self.filled,
color=self.color, color=self.color,
) )
return icon self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
def get_icon(self):
"""
Returns the icon for the action.
Returns:
QIcon: The icon for the action.
"""
return self.icon
class DeviceSelectionAction(ToolBarAction): class DeviceSelectionAction(ToolBarAction):
@@ -132,10 +203,9 @@ class DeviceSelectionAction(ToolBarAction):
Args: Args:
label (str): The label for the combobox. label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device. device_combobox (DeviceComboBox): The combobox for selecting the device.
""" """
def __init__(self, label: str, device_combobox): def __init__(self, label: str | None = None, device_combobox=None):
super().__init__() super().__init__()
self.label = label self.label = label
self.device_combobox = device_combobox self.device_combobox = device_combobox
@@ -144,15 +214,131 @@ class DeviceSelectionAction(ToolBarAction):
def add_to_toolbar(self, toolbar, target): def add_to_toolbar(self, toolbar, target):
widget = QWidget() widget = QWidget()
layout = QHBoxLayout(widget) layout = QHBoxLayout(widget)
label = QLabel(f"{self.label}") layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(label) layout.setSpacing(0)
layout.addWidget(self.device_combobox) if self.label is not None:
toolbar.addWidget(widget) label = QLabel(f"{self.label}")
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str): def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class SwitchableToolBarAction(ToolBarAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
alternative action is selected, it becomes the new default and its callback is immediately executed.
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
Args:
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
checkable (bool, optional): Whether the action is checkable. Defaults to True.
parent (QWidget, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.actions = actions
self.current_key = initial_action if initial_action is not None else next(iter(actions))
self.parent = parent
self.checkable = checkable
self.default_state_checked = default_state_checked
self.main_button = None
self.menu_actions: Dict[str, QAction] = {}
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the split action to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.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.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.main_button.setMenu(menu)
toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
"""
Triggers the current action associated with the main button.
"""
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
"""
Sets the default action for the split action.
Args:
key(str): The key of the action to set as default.
"""
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.actions.items():
menu_act.action.setChecked(False)
new_action.action.trigger()
# Active action chosen from menu is always checked, uncheck through main button
if self.checkable:
new_action.action.setChecked(True)
self.main_button.setChecked(True)
def block_all_signals(self, block: bool = True):
"""
Blocks or unblocks all signals for the actions in the toolbar.
Args:
block (bool): Whether to block signals. Defaults to True.
"""
self.main_button.blockSignals(block)
for action in self.actions.values():
action.action.blockSignals(block)
def set_state_all(self, state: bool):
"""
Uncheck all actions in the toolbar.
"""
for action in self.actions.values():
action.action.setChecked(state)
self.main_button.setChecked(state)
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
class WidgetAction(ToolBarAction): class WidgetAction(ToolBarAction):
""" """
Action for adding any widget to the toolbar. Action for adding any widget to the toolbar.
@@ -160,19 +346,33 @@ class WidgetAction(ToolBarAction):
Args: Args:
label (str|None): The label for the widget. label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar. widget (QWidget): The widget to be added to the toolbar.
""" """
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None): def __init__(
super().__init__(parent) self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
parent=None,
):
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label self.label = label
self.widget = widget self.widget = widget
self.container = None
self.adjust_size = adjust_size
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
container = QWidget() """
layout = QHBoxLayout(container) Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget()
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5) layout.setSpacing(0)
if self.label is not None: if self.label is not None:
label_widget = QLabel(f"{self.label}") label_widget = QLabel(f"{self.label}")
@@ -180,7 +380,7 @@ class WidgetAction(ToolBarAction):
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget) layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox): if isinstance(self.widget, QComboBox) and self.adjust_size:
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -193,19 +393,12 @@ class WidgetAction(ToolBarAction):
layout.addWidget(self.widget) layout.addWidget(self.widget)
toolbar.addWidget(container) toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.container
@staticmethod @staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int: def calculate_minimum_width(combo_box: QComboBox) -> int:
"""
Calculate the minimum width required to display the longest item in the combo box.
Args:
combo_box (QComboBox): The combo box to calculate the width for.
Returns:
int: The calculated minimum width in pixels.
"""
font_metrics = combo_box.fontMetrics() 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()))
return max_width + 60 return max_width + 60
@@ -219,7 +412,6 @@ class ExpandableMenuAction(ToolBarAction):
label (str): The label for the menu. label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu. actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None. 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, icon_path: str = None):
@@ -246,12 +438,15 @@ class ExpandableMenuAction(ToolBarAction):
menu = QMenu(button) menu = QMenu(button)
for action_id, action in self.actions.items(): for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target) sub_action = QAction(action.tooltip, target)
if hasattr(action, "icon_path"): sub_action.setIconVisibleInMenu(True)
if action.icon_path:
icon = QIcon() icon = QIcon()
icon.addFile(action.icon_path, size=QSize(20, 20)) icon.addFile(action.icon_path, size=QSize(20, 20))
sub_action.setIcon(icon) sub_action.setIcon(icon)
elif hasattr(action, "get_icon"): elif hasattr(action, "get_icon") and callable(action.get_icon):
sub_action.setIcon(action.get_icon()) sub_icon = action.get_icon()
if sub_icon and not sub_icon.isNull():
sub_action.setIcon(sub_icon)
sub_action.setCheckable(action.checkable) sub_action.setCheckable(action.checkable)
menu.addAction(sub_action) menu.addAction(sub_action)
self.widgets[action_id] = sub_action self.widgets[action_id] = sub_action
@@ -259,38 +454,547 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button) toolbar.addWidget(button)
class ModularToolBar(QToolBar): class ToolbarBundle:
"""Modular toolbar with optional automatic initialization. """
Args: Represents a bundle of toolbar actions, keyed by action_id.
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None. Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object.
actions (list[ToolBarAction], optional): A list of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
""" """
def __init__(self, parent=None, actions: dict | None = None, target_widget=None): def __init__(self, bundle_id: str = None, actions=None):
"""
Args:
bundle_id (str): Unique identifier for the bundle.
actions: Either None or a list of (action_id, ToolBarAction) tuples.
"""
self.bundle_id = bundle_id
self._actions: dict[str, ToolBarAction] = {}
if actions is not None:
for action_id, action in actions:
self._actions[action_id] = action
def add_action(self, action_id: str, action: ToolBarAction):
"""
Adds or replaces an action in the bundle.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
self._actions[action_id] = action
def remove_action(self, action_id: str):
"""
Removes an action from the bundle by ID.
Ignores if not present.
Args:
action_id (str): Unique identifier for the action to remove.
"""
self._actions.pop(action_id, None)
@property
def actions(self) -> dict[str, ToolBarAction]:
"""
Return the internal dictionary of actions so that you can do
bundle.actions["drag_mode"] -> ToolBarAction instance.
"""
return self._actions
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
"""
def __init__(
self,
parent=None,
actions: dict | None = None,
target_widget=None,
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent) super().__init__(parent)
self.widgets = defaultdict(dict) self.widgets = defaultdict(dict)
self.set_background_color() self.background_color = background_color
self.set_background_color(self.background_color)
# Set the initial orientation
self.set_orientation(orientation)
# Initialize bundles
self.bundles = {}
self.toolbar_items = []
if actions is not None and target_widget is not None: if actions is not None and target_widget is not None:
self.populate_toolbar(actions, target_widget) self.populate_toolbar(actions, target_widget)
def populate_toolbar(self, actions: dict, target_widget): def populate_toolbar(self, actions: dict, target_widget: QWidget):
"""Populates the toolbar with a set of actions. """Populates the toolbar with a set of actions.
Args: Args:
actions (list[ToolBarAction]): A list of action creators to populate the toolbar. actions (dict): A dictionary of action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target. target_widget (QWidget): The widget that the actions will target.
""" """
self.clear() self.clear()
self.toolbar_items.clear() # Reset the order tracking
for action_id, action in actions.items(): for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget) action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators() # Ensure separators are updated after populating
def set_background_color(self): def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
"""
Sets the background color and other appearance settings.
Args:
color (str): The background color of the toolbar.
"""
self.setIconSize(QSize(20, 20)) self.setIconSize(QSize(20, 20))
self.setMovable(False) self.setMovable(False)
self.setFloatable(False) self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0) self.setContentsMargins(0, 0, 0, 0)
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }") self.background_color = color
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
"""Sets the orientation of the toolbar.
Args:
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
"""
if orientation == "horizontal":
self.setOrientation(Qt.Horizontal)
elif orientation == "vertical":
self.setOrientation(Qt.Vertical)
else:
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
def update_material_icon_colors(self, new_color: str | tuple | QColor):
"""
Updates the color of all MaterialIconAction icons.
Args:
new_color (str | tuple | QColor): The new color.
"""
for action in self.widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
updated_icon = action.get_icon()
action.action.setIcon(updated_icon)
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
"""
Adds a new standalone action dynamically.
Args:
action_id (str): Unique identifier.
action (ToolBarAction): The action to add.
target_widget (QWidget): The target widget.
"""
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
self.toolbar_items.append(("action", action_id))
self.update_separators()
def hide_action(self, action_id: str):
"""
Hides a specific action.
Args:
action_id (str): Unique identifier.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(False)
self.update_separators()
def show_action(self, action_id: str):
"""
Shows a specific action.
Args:
action_id (str): Unique identifier.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
action = self.widgets[action_id]
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(True)
self.update_separators()
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle.
target_widget (QWidget): The target widget.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators()
def add_action_to_bundle(self, bundle_id: str, action_id: str, action, target_widget: QWidget):
"""
Dynamically adds an action to an existing bundle.
Args:
bundle_id (str): The bundle ID.
action_id (str): Unique identifier.
action (ToolBarAction): The action to add.
target_widget (QWidget): The target widget.
"""
if bundle_id not in self.bundles:
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
if action_id in self.widgets:
raise ValueError(f"Action with ID '{action_id}' already exists.")
action.add_to_toolbar(self, target_widget)
new_qaction = action.action
self.removeAction(new_qaction)
bundle_action_ids = self.bundles[bundle_id]
if bundle_action_ids:
last_bundle_action = self.widgets[bundle_action_ids[-1]].action
actions_list = self.actions()
try:
index = actions_list.index(last_bundle_action)
except ValueError:
self.addAction(new_qaction)
else:
if index + 1 < len(actions_list):
before_action = actions_list[index + 1]
self.insertAction(before_action, new_qaction)
else:
self.addAction(new_qaction)
else:
self.addAction(new_qaction)
self.widgets[action_id] = action
self.bundles[bundle_id].append(action_id)
self.update_separators()
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show toolbar actions with checkboxes and icons.
Args:
event (QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
for item_type, identifier in self.toolbar_items:
if item_type == "separator":
menu.addSeparator()
elif item_type == "bundle":
self.handle_bundle_context_menu(menu, identifier)
elif item_type == "action":
self.handle_action_context_menu(menu, identifier)
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds bundle actions to the context menu.
Args:
menu (QMenu): The context menu.
bundle_id (str): The bundle identifier.
"""
action_ids = self.bundles.get(bundle_id, [])
for act_id in action_ids:
toolbar_action = self.widgets.get(act_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
toolbar_action, "action"
):
continue
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
continue
display_name = qaction.text() or toolbar_action.tooltip or act_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(act_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
"""
Adds a single toolbar action to the context menu.
Args:
menu (QMenu): The context menu to which the action is added.
action_id (str): Unique identifier for the action.
"""
toolbar_action = self.widgets.get(action_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
return
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
return
display_name = qaction.text() or toolbar_action.tooltip or action_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(action_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""
Handles the triggered signal from the context menu.
Args:
action: Action triggered.
"""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id, action.isChecked())
def toggle_action_visibility(self, action_id: str, visible: bool):
"""
Toggles the visibility of a specific action.
Args:
action_id (str): Unique identifier.
visible (bool): Whether the action should be visible.
"""
if action_id not in self.widgets:
return
tool_action = self.widgets[action_id]
if hasattr(tool_action, "action") and tool_action.action is not None:
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no non-separator actions between them.
"""
toolbar_actions = self.actions()
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
):
action.setVisible(False)
else:
action.setVisible(True)
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
else:
prev = action
else:
if action.isVisible():
prev = action
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.test_label = QLabel(text="This is a test label.")
self.central_widget.layout = QVBoxLayout(self.central_widget)
self.central_widget.layout.addWidget(self.test_label)
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
self.add_switchable_button_checkable()
self.add_switchable_button_non_checkable()
self.add_widget_actions()
self.add_bundles()
self.add_menus()
# For theme testing
self.dark_button = DarkModeButton(toolbar=True)
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
self.toolbar.add_action("dark_mode", dark_mode_action, self)
def add_bundles(self):
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=False, parent=self
)
settings_action = MaterialIconAction(
icon_name="settings", tooltip="Settings", checkable=True, parent=self
)
profile_action = MaterialIconAction(
icon_name="person", tooltip="Profile", checkable=True, parent=self
)
main_actions_bundle = ToolbarBundle(
bundle_id="main_actions",
actions=[
("home_action", home_action),
("settings_action", settings_action),
("profile_action", profile_action),
],
)
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
home_action.action.triggered.connect(lambda: self.switchable_action.set_state_all(False))
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=False, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=False, parent=self
)
second_bundle = ToolbarBundle(
bundle_id="secondary_actions",
actions=[("search_action", search_action), ("help_action", help_action)],
)
self.toolbar.add_bundle(second_bundle, target_widget=self)
new_action = MaterialIconAction(
icon_name="counter_1", tooltip="New Action", checkable=True, parent=self
)
self.toolbar.add_action_to_bundle(
"main_actions", "new_action", new_action, target_widget=self
)
def add_menus(self):
menu_material_actions = {
"mat1": MaterialIconAction(
icon_name="home", tooltip="Material Home", checkable=True, parent=self
),
"mat2": MaterialIconAction(
icon_name="settings", tooltip="Material Settings", checkable=True, parent=self
),
"mat3": MaterialIconAction(
icon_name="info", tooltip="Material Info", checkable=True, parent=self
),
}
menu_qt_actions = {
"qt1": QtIconAction(
standard_icon=QStyle.SP_FileIcon, tooltip="Qt File", checkable=True, parent=self
),
"qt2": QtIconAction(
standard_icon=QStyle.SP_DirIcon, tooltip="Qt Directory", checkable=True, parent=self
),
"qt3": QtIconAction(
standard_icon=QStyle.SP_TrashIcon, tooltip="Qt Trash", checkable=True, parent=self
),
}
expandable_menu_material = ExpandableMenuAction(
label="Material Menu", actions=menu_material_actions
)
expandable_menu_qt = ExpandableMenuAction(label="Qt Menu", actions=menu_qt_actions)
self.toolbar.add_action("material_menu", expandable_menu_material, self)
self.toolbar.add_action("qt_menu", expandable_menu_qt, self)
def add_switchable_button_checkable(self):
action1 = MaterialIconAction(
icon_name="hdr_auto", tooltip="Action 1", checkable=True, parent=self
)
action2 = MaterialIconAction(
icon_name="hdr_auto", tooltip="Action 2", checkable=True, filled=True, parent=self
)
self.switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action", self.switchable_action, self)
action1.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
)
action2.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
)
def add_switchable_button_non_checkable(self):
action1 = MaterialIconAction(
icon_name="counter_1", tooltip="Action 1", checkable=False, parent=self
)
action2 = MaterialIconAction(
icon_name="counter_2", tooltip="Action 2", checkable=False, parent=self
)
switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=False,
parent=self,
)
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
action1.action.triggered.connect(
lambda checked: self.test_label.setText(
f"Action 1 (non-checkable) triggered, checked = {checked}"
)
)
action2.action.triggered.connect(
lambda checked: self.test_label.setText(
f"Action 2 (non-checkable) triggered, checked = {checked}"
)
)
switchable_action.actions["action1"].action.setChecked(True)
def add_widget_actions(self):
combo = QComboBox()
combo.addItems(["Option 1", "Option 2", "Option 3"])
self.toolbar.add_action("device_combo", WidgetAction(label="Device:", widget=combo), self)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
set_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())
+8
View File
@@ -224,3 +224,11 @@ DEVICES = [
Positioner("test", limits=[-10, 10], read_value=2.0), Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"), Device("test_device"),
] ]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
+99 -33
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
import os import os
import time import time
import uuid import uuid
from typing import Optional from datetime import datetime
from typing import TYPE_CHECKING, Optional
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from from bec_lib.utils.import_utils import lazy_import_from
@@ -12,11 +13,15 @@ from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc_register import RPCRegister from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING:
from bec_widgets.utils.bec_dispatcher import BECDispatcher
logger = bec_logger.logger logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",)) BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -36,8 +41,7 @@ class ConnectionConfig(BaseModel):
"""Generate a GUI ID if none is provided.""" """Generate a GUI ID if none is provided."""
if v is None: if v is None:
widget_class = values.data["widget_class"] widget_class = values.data["widget_class"]
v = f"{widget_class}_{str(time.time())}" v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
return v
return v return v
@@ -69,10 +73,16 @@ class Worker(QRunnable):
class BECConnector: class BECConnector:
"""Connection mixin class to handle BEC client and device manager""" """Connection mixin class to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc"] USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {} EXIT_HANDLERS = {}
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None): def __init__(
self,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
name: str | None = None,
):
# BEC related connections # BEC related connections
self.bec_dispatcher = BECDispatcher(client=client) self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client self.client = self.bec_dispatcher.client if client is None else client
@@ -100,15 +110,22 @@ class BECConnector:
) )
self.config = ConnectionConfig(widget_class=self.__class__.__name__) self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# I feel that we should not allow BECConnector to be created with a custom gui_id
# because this would break with the logic in the RPCRegister of retrieving widgets by type
# iterating over all widgets and checkinf if the register widget starts with the string that is passsed.
# If the gui_id is randomly generated, this would break since that widget would have a
# gui_id that is generated in a different way.
if gui_id: if gui_id:
self.config.gui_id = gui_id self.config.gui_id = gui_id
self.gui_id = gui_id self.gui_id: str = gui_id
else: else:
self.gui_id = self.config.gui_id self.gui_id: str = self.config.gui_id # type: ignore
if name is None:
# register widget to rpc register name = self.__class__.__name__
# be careful: when registering, and the object is not a BECWidget, else:
# cleanup has to called manually since there is no 'closeEvent' if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
self.rpc_register = RPCRegister() self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self) self.rpc_register.add_rpc(self)
@@ -116,6 +133,8 @@ class BECConnector:
self.error_utility = ErrorPopupUtility() self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance() self._thread_pool = QThreadPool.globalInstance()
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker: def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
""" """
@@ -144,11 +163,14 @@ class BECConnector:
>>> def on_complete(): >>> def on_complete():
>>> print("Task complete") >>> print("Task complete")
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete) >>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
""" """
worker = Worker(fn, *args, **kwargs) worker = Worker(fn, *args, **kwargs)
if on_complete: if on_complete:
worker.signals.completed.connect(on_complete) worker.signals.completed.connect(on_complete)
# Keep a reference to the worker so it is not garbage collected.
self._workers.append(worker)
# When the worker is done, remove it from our list.
worker.signals.completed.connect(lambda: self._workers.remove(worker))
self._thread_pool.start(worker) self._thread_pool.start(worker)
return worker return worker
@@ -180,37 +202,39 @@ class BECConnector:
@_config_dict.setter @_config_dict.setter
def _config_dict(self, config: BaseModel) -> None: def _config_dict(self, config: BaseModel) -> None:
""" """
Get the configuration of the widget. Set the configuration of the widget.
Returns: Args:
dict: The configuration of the widget. config (BaseModel): The new configuration model.
""" """
self.config = config self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None: def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
""" """
Apply the configuration to the widget. Apply the configuration to the widget.
Args: Args:
config(dict): Configuration settings. config (dict): Configuration settings.
generate_new_id(bool): If True, generate a new GUI ID for the widget. generate_new_id (bool): If True, generate a new GUI ID for the widget.
""" """
self.config = ConnectionConfig(**config) self.config = ConnectionConfig(**config)
if generate_new_id is True: if generate_new_id is True:
gui_id = str(uuid.uuid4()) gui_id = str(uuid.uuid4())
self.rpc_register.remove_rpc(self) self.rpc_register.remove_rpc(self)
self.set_gui_id(gui_id) self._set_gui_id(gui_id)
self.rpc_register.add_rpc(self) self.rpc_register.add_rpc(self)
else: else:
self.gui_id = self.config.gui_id self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False): def load_config(self, path: str | None = None, gui: bool = False):
""" """
Load the configuration of the widget from YAML. Load the configuration of the widget from YAML.
Args: Args:
path(str): Path to the configuration file for non-GUI dialog mode. path (str | None): Path to the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to load the configuration file. gui (bool): If True, use the GUI dialog to load the configuration file.
""" """
if gui is True: if gui is True:
config = load_yaml_gui(self) config = load_yaml_gui(self)
@@ -229,8 +253,8 @@ class BECConnector:
Save the configuration of the widget to YAML. Save the configuration of the widget to YAML.
Args: Args:
path(str): Path to save the configuration file for non-GUI dialog mode. path (str | None): Path to save the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to save the configuration file. gui (bool): If True, use the GUI dialog to save the configuration file.
""" """
if gui is True: if gui is True:
save_yaml_gui(self, self._config_dict) save_yaml_gui(self, self._config_dict)
@@ -238,16 +262,15 @@ class BECConnector:
if path is None: if path is None:
path = os.getcwd() path = os.getcwd()
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml") file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict) save_yaml(file_path, self._config_dict)
@pyqtSlot(str) # @pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None: def _set_gui_id(self, gui_id: str) -> None:
""" """
Set the GUI ID for the widget. Set the GUI ID for the widget.
Args: Args:
gui_id(str): GUI ID gui_id (str): GUI ID.
""" """
self.config.gui_id = gui_id self.config.gui_id = gui_id
self.gui_id = gui_id self.gui_id = gui_id
@@ -268,7 +291,7 @@ class BECConnector:
"""Update the client and device manager from BEC and create object for BEC shortcuts. """Update the client and device manager from BEC and create object for BEC shortcuts.
Args: Args:
client: BEC client client: BEC client.
""" """
self.client = client self.client = client
self.get_bec_shortcuts() self.get_bec_shortcuts()
@@ -279,25 +302,68 @@ class BECConnector:
Update the configuration for the widget. Update the configuration for the widget.
Args: Args:
config(ConnectionConfig): Configuration settings. config (ConnectionConfig | dict): Configuration settings.
""" """
gui_id = getattr(config, "gui_id", None)
if isinstance(config, dict): if isinstance(config, dict):
config = ConnectionConfig(**config) config = ConnectionConfig(**config)
# TODO add error handler
self.config = config self.config = config
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
self.config.gui_id = gui_id
def remove(self):
"""Cleanup the BECConnector"""
if hasattr(self, "close"):
self.close()
if hasattr(self, "deleteLater"):
self.deleteLater()
else:
self.rpc_register.remove_rpc(self)
def get_config(self, dict_output: bool = True) -> dict | BaseModel: def get_config(self, dict_output: bool = True) -> dict | BaseModel:
""" """
Get the configuration of the widget. Get the configuration of the widget.
Args: Args:
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model. dict_output (bool): If True, return the configuration as a dictionary.
If False, return the configuration as a pydantic model.
Returns: Returns:
dict: The configuration of the plot widget. dict | BaseModel: The configuration of the widget.
""" """
if dict_output: if dict_output:
return self.config.model_dump() return self.config.model_dump()
else: else:
return self.config return self.config
# --- Example usage of BECConnector: running a simple task ---
if __name__ == "__main__": # pragma: no cover
import sys
# Create a QApplication instance (required for QThreadPool)
app = QApplication(sys.argv)
connector = BECConnector()
def print_numbers():
"""
Task function that prints numbers 1 to 10 with a 0.5 second delay between each.
"""
for i in range(1, 11):
print(i)
time.sleep(0.5)
def task_complete():
"""
Called when the task is complete.
"""
print("Task complete")
# Exit the application after the task completes.
app.quit()
# Submit the task using the connector's submit_task method.
connector.submit_task(print_numbers, on_complete=task_complete)
# Start the Qt event loop.
sys.exit(app.exec_())
+12 -5
View File
@@ -93,17 +93,24 @@ def patch_designer(): # pragma: no cover
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True) _extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
else: else:
if sys.platform == "linux": if sys.platform == "linux":
suffix = f"{sys.abiflags}.so"
env_var = "LD_PRELOAD" env_var = "LD_PRELOAD"
current_pid = os.getpid()
with open(f"/proc/{current_pid}/maps", "rt") as f:
for line in f:
if "libpython" in line:
lib_path = line.split()[-1]
os.environ[env_var] = lib_path
break
elif sys.platform == "darwin": elif sys.platform == "darwin":
suffix = ".dylib" suffix = ".dylib"
env_var = "DYLD_INSERT_LIBRARIES" env_var = "DYLD_INSERT_LIBRARIES"
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{suffix}"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ[env_var] = lib_path
else: else:
raise RuntimeError(f"Unsupported platform: {sys.platform}") raise RuntimeError(f"Unsupported platform: {sys.platform}")
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{suffix}"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ[env_var] = lib_path
if is_pyenv_python() or is_virtual_env(): if is_pyenv_python() or is_virtual_env():
# append all editable packages to the PYTHONPATH # append all editable packages to the PYTHONPATH
+47 -11
View File
@@ -5,28 +5,43 @@ analyse data. Requesting a new fit may lead to request piling up and an overall
will allow you to decide by yourself when to unblock and execute the callback again.""" will allow you to decide by yourself when to unblock and execute the callback again."""
from pyqtgraph import SignalProxy from pyqtgraph import SignalProxy
from qtpy.QtCore import Signal, Slot from qtpy.QtCore import QTimer, Signal
from bec_widgets.qt_utils.error_popups import SafeSlot
class BECSignalProxy(SignalProxy): class BECSignalProxy(SignalProxy):
"""Thin wrapper around the SignalProxy class to allow signal calls to be blocked, but args still being stored """
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
but arguments still being stored.
Args: Args:
*args: Arguments to pass to the SignalProxy class *args: Arguments to pass to the SignalProxy class.
rateLimit (int): The rateLimit of the proxy rateLimit (int): The rateLimit of the proxy.
**kwargs: Keyword arguments to pass to the SignalProxy class timeout (float): The number of seconds after which the proxy automatically
unblocks if still blocked. Default is 10.0 seconds.
**kwargs: Keyword arguments to pass to the SignalProxy class.
Example: Example:
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)""" >>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
"""
is_blocked = Signal(bool) is_blocked = Signal(bool)
def __init__(self, *args, rateLimit=25, **kwargs): def __init__(self, *args, rateLimit=25, timeout=10.0, **kwargs):
super().__init__(*args, rateLimit=rateLimit, **kwargs) super().__init__(*args, rateLimit=rateLimit, **kwargs)
self._blocking = False self._blocking = False
self.old_args = None self.old_args = None
self.new_args = None self.new_args = None
# Store timeout value (in seconds)
self._timeout = timeout
# Create a single-shot timer for auto-unblocking
self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._timeout_unblock)
@property @property
def blocked(self): def blocked(self):
"""Returns if the proxy is blocked""" """Returns if the proxy is blocked"""
@@ -46,9 +61,30 @@ class BECSignalProxy(SignalProxy):
self.old_args = args self.old_args = args
super().signalReceived(*args) super().signalReceived(*args)
@Slot() self._timer.start(int(self._timeout * 1000))
@SafeSlot()
def unblock_proxy(self): def unblock_proxy(self):
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args.""" """Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
self.blocked = False if self.blocked:
if self.new_args != self.old_args: self._timer.stop()
self.signalReceived(*self.new_args) self.blocked = False
if self.new_args != self.old_args:
self.signalReceived(*self.new_args)
@SafeSlot()
def _timeout_unblock(self):
"""
Internal method called by the QTimer upon timeout. Unblocks the proxy
automatically if it is still blocked.
"""
if self.blocked:
self.unblock_proxy()
def cleanup(self):
"""
Cleanup the proxy by stopping the timer and disconnecting the timeout signal.
"""
self._timer.stop()
self._timer.timeout.disconnect(self._timeout_unblock)
self._timer.deleteLater()
+19 -6
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
import darkdetect import darkdetect
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot from qtpy.QtCore import Slot
@@ -7,6 +9,10 @@ from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger logger = bec_logger.logger
@@ -17,13 +23,18 @@ class BECWidget(BECConnector):
# The icon name is the name of the icon in the icon theme, typically a name taken # 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. # from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets" ICON_NAME = "widgets"
USER_ACCESS = ["remove"]
# pylint: disable=too-many-arguments
def __init__( def __init__(
self, self,
client=None, client=None,
config: ConnectionConfig = None, config: ConnectionConfig = None,
gui_id: str = None, gui_id: str | None = None,
theme_update: bool = False, theme_update: bool = False,
name: str | None = None,
parent_dock: BECDock | None = None,
**kwargs,
): ):
""" """
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.: Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
@@ -44,9 +55,9 @@ class BECWidget(BECConnector):
""" """
if not isinstance(self, QWidget): if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client=client, config=config, gui_id=gui_id)
# Set the theme to auto if it is not set yet super().__init__(client=client, config=config, gui_id=gui_id, name=name)
self._parent_dock = parent_dock
app = QApplication.instance() app = QApplication.instance()
if not hasattr(app, "theme"): if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault # DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
@@ -66,7 +77,7 @@ class BECWidget(BECConnector):
if hasattr(qapp, "theme_signal"): if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme) qapp.theme_signal.theme_updated.connect(self._update_theme)
def _update_theme(self, theme: str): def _update_theme(self, theme: str | None = None):
"""Update the theme.""" """Update the theme."""
if theme is None: if theme is None:
qapp = QApplication.instance() qapp = QApplication.instance()
@@ -87,10 +98,12 @@ class BECWidget(BECConnector):
def cleanup(self): def cleanup(self):
"""Cleanup the widget.""" """Cleanup the widget."""
# All widgets need to call super().cleanup() in their cleanup method
self.rpc_register.remove_rpc(self)
def closeEvent(self, event): def closeEvent(self, event):
self.rpc_register.remove_rpc(self) """Wrap the close even to ensure the rpc_register is cleaned up."""
try: try:
self.cleanup() self.cleanup()
finally: finally:
super().closeEvent(event) super().closeEvent(event) # pylint: disable=no-member
+58 -10
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import itertools
import re import re
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
@@ -71,15 +70,64 @@ 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 to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
""" """
app = QApplication.instance() app = QApplication.instance()
# go through all pyqtgraph widgets and set background graphic_layouts = [
children = itertools.chain.from_iterable( child
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets() for top in app.topLevelWidgets()
) for child in top.findChildren(pg.GraphicsLayoutWidget)
pg.setConfigOptions( ]
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
) plot_items = [
for pg_widget in children: item
pg_widget.setBackground("k" if theme == "dark" else "w") 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 # now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme) style = bec_qthemes.load_stylesheet(theme)
+37 -12
View File
@@ -1,30 +1,55 @@
from __future__ import annotations from __future__ import annotations
import itertools import itertools
from typing import Type from typing import Literal, Type
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
class WidgetContainerUtils: class WidgetContainerUtils:
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
# or alternatively raise an error that it can't be added again ( just raise an error)
# 2. Dock names in between docks should also be unique
@staticmethod @staticmethod
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str: def has_name_valid_chars(name: str) -> bool:
""" """Check if the name is valid.
Generate a unique widget ID.
Args: Args:
container(dict): The container of widgets. name(str): The name to be checked.
prefix(str): The prefix of the widget ID.
Returns: Returns:
widget_id(str): The unique widget ID. bool: True if the name is valid, False otherwise.
""" """
existing_ids = set(container.keys()) if not name or len(name) > 256:
for i in itertools.count(1): return False # Don't accept empty names or names longer than 256 characters
widget_id = f"{prefix}_{i}" check_value = name.replace("_", "").replace("-", "")
if widget_id not in existing_ids: if not check_value.isalnum() or not check_value.isascii():
return widget_id return False
return True
@staticmethod
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
"""Generate a unique ID.
Args:
name(str): The name of the widget.
Returns:
tuple (str): The unique name
"""
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate
ii += 1
raise ValueError("Could not generate a unique name after within 1000 attempts.")
@staticmethod @staticmethod
def find_first_widget_by_class( def find_first_widget_by_class(
+112 -42
View File
@@ -1,4 +1,7 @@
from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import Any
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
@@ -6,13 +9,16 @@ from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication from qtpy.QtWidgets import QApplication
class NonDownsamplingScatterPlotItem(pg.ScatterPlotItem): class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None): def setDownsampling(self, ds=None, auto=None, method=None):
pass pass
def setClipToView(self, state): def setClipToView(self, state):
pass pass
def setAlpha(self, *args, **kwargs):
pass
class Crosshair(QObject): class Crosshair(QObject):
# QT Position of mouse cursor # QT Position of mouse cursor
@@ -47,9 +53,15 @@ class Crosshair(QObject):
self.v_line.skip_auto_range = True self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False) self.h_line = pg.InfiniteLine(angle=0, movable=False)
self.h_line.skip_auto_range = True self.h_line.skip_auto_range = True
# Add custom attribute to identify crosshair lines
self.v_line.is_crosshair = True
self.h_line.is_crosshair = True
self.plot_item.addItem(self.v_line, ignoreBounds=True) self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True) self.plot_item.addItem(self.h_line, ignoreBounds=True)
# Initialize highlighted curve in a case of multiple curves
self.highlighted_curve_index = None
# Add TextItem to display coordinates # Add TextItem to display coordinates
self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100)) self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
self.coord_label.setVisible(False) # Hide initially self.coord_label.setVisible(False) # Hide initially
@@ -70,6 +82,7 @@ class Crosshair(QObject):
self.plot_item.ctrl.downsampleSpin.valueChanged.connect(self.clear_markers) self.plot_item.ctrl.downsampleSpin.valueChanged.connect(self.clear_markers)
# Initialize markers # Initialize markers
self.items = []
self.marker_moved_1d = {} self.marker_moved_1d = {}
self.marker_clicked_1d = {} self.marker_clicked_1d = {}
self.marker_2d = None self.marker_2d = None
@@ -113,61 +126,103 @@ class Crosshair(QObject):
self.coord_label.fill = pg.mkBrush(label_bg_color) self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None) self.coord_label.border = pg.mkPen(None)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
Args:
curve_index(int): The index of curve to highlight
"""
self.highlighted_curve_index = curve_index
self.clear_markers()
self.update_markers()
def update_markers(self): def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary.""" """Update the markers for the crosshair, creating new ones if necessary."""
# Create new markers if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
for item in self.plot_item.items: # Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
else:
# Handle all curves
self.items = self.plot_item.items
# Create or update markers
for item in self.items:
if isinstance(item, pg.PlotDataItem): # 1D plot if isinstance(item, pg.PlotDataItem): # 1D plot
if item.name() in self.marker_moved_1d:
continue
pen = item.opts["pen"] pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen) color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
marker_moved = NonDownsamplingScatterPlotItem( name = item.name() or str(id(item))
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None) if name in self.marker_moved_1d:
) # Update existing markers
marker_moved.skip_auto_range = True marker_moved = self.marker_moved_1d[name]
self.marker_moved_1d[item.name()] = marker_moved marker_moved.setPen(pg.mkPen(color))
self.plot_item.addItem(marker_moved) # Update clicked markers' brushes
for marker_clicked in self.marker_clicked_1d[name]:
# Create glowing effect markers for clicked events alpha = marker_clicked.opts["brush"].color().alpha()
for size, alpha in [(18, 64), (14, 128), (10, 255)]: marker_clicked.setBrush(
marker_clicked = NonDownsamplingScatterPlotItem( pg.mkBrush(color.red(), color.green(), color.blue(), alpha)
size=size, )
pen=pg.mkPen(None), # Update z-values
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha), marker_moved.setZValue(item.zValue() + 1)
for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setZValue(item.zValue() + 1)
else:
# Create new markers
marker_moved = CrosshairScatterItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
) )
marker_clicked.skip_auto_range = True marker_moved.skip_auto_range = True
self.marker_clicked_1d[item.name()] = marker_clicked marker_moved.is_crosshair = True
self.plot_item.addItem(marker_clicked) self.marker_moved_1d[name] = marker_moved
self.plot_item.addItem(marker_moved)
# Set marker z-value higher than the curve
marker_moved.setZValue(item.zValue() + 1)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = CrosshairScatterItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_clicked.skip_auto_range = True
marker_clicked.is_crosshair = True
self.plot_item.addItem(marker_clicked)
marker_clicked.setZValue(item.zValue() + 1)
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None: if self.marker_2d is not None:
continue continue
self.marker_2d = pg.ROI( self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False [0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
) )
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d) self.plot_item.addItem(self.marker_2d)
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]: def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
""" """
Finds the nearest data points to the given x and y coordinates. Finds the nearest data points to the given x and y coordinates.
Args: Args:
x: The x-coordinate of the mouse cursor x(float): The x-coordinate of the mouse cursor
y: The y-coordinate of the mouse cursor y(float): The y-coordinate of the mouse cursor
Returns: Returns:
tuple: x and y values snapped to the nearest data tuple: x and y values snapped to the nearest data
""" """
y_values = defaultdict(list) y_values = defaultdict(list)
x_values = defaultdict(list) x_values = defaultdict(list)
image_2d = None
# Iterate through items in the plot # Iterate through items in the plot
for item in self.plot_item.items: for item in self.items:
if isinstance(item, pg.PlotDataItem): # 1D plot if isinstance(item, pg.PlotDataItem): # 1D plot
name = item.name() name = item.name() or str(id(item))
plot_data = item._getDisplayDataset() plot_data = item._getDisplayDataset()
if plot_data is None: if plot_data is None:
continue continue
@@ -186,9 +241,9 @@ class Crosshair(QObject):
y_values[name] = closest_y y_values[name] = closest_y
x_values[name] = closest_x x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor name = item.config.monitor or str(id(item))
image_2d = item.image image_2d = item.image
# clip the x and y values to the image dimensions to avoid out of bounds errors # Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1)) y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1)) x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
@@ -256,9 +311,9 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe... # not sure how we got here, but just to be safe...
return return
for item in self.plot_item.items: for item in self.items:
if isinstance(item, pg.PlotDataItem): if isinstance(item, pg.PlotDataItem):
name = item.name() name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
@@ -271,7 +326,7 @@ class Crosshair(QObject):
) )
self.coordinatesChanged1D.emit(coordinate_to_emit) self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem): elif isinstance(item, pg.ImageItem):
name = item.config.monitor name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
@@ -309,13 +364,14 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe... # not sure how we got here, but just to be safe...
return return
for item in self.plot_item.items: for item in self.items:
if isinstance(item, pg.PlotDataItem): if isinstance(item, pg.PlotDataItem):
name = item.name() name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
self.marker_clicked_1d[name].setData([x], [y]) for marker_clicked in self.marker_clicked_1d[name]:
marker_clicked.setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y) x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = ( coordinate_to_emit = (
name, name,
@@ -324,7 +380,7 @@ class Crosshair(QObject):
) )
self.coordinatesClicked1D.emit(coordinate_to_emit) self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem): elif isinstance(item, pg.ImageItem):
name = item.config.monitor name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
@@ -337,9 +393,12 @@ class Crosshair(QObject):
def clear_markers(self): def clear_markers(self):
"""Clears the markers from the plot.""" """Clears the markers from the plot."""
for marker in self.marker_moved_1d.values(): for marker in self.marker_moved_1d.values():
marker.clear() self.plot_item.removeItem(marker)
for marker in self.marker_clicked_1d.values(): for markers in self.marker_clicked_1d.values():
marker.clear() for marker in markers:
self.plot_item.removeItem(marker)
self.marker_moved_1d.clear()
self.marker_clicked_1d.clear()
def scale_emitted_coordinates(self, x, y): def scale_emitted_coordinates(self, x, y):
"""Scales the emitted coordinates if the axes are in log scale. """Scales the emitted coordinates if the axes are in log scale.
@@ -365,9 +424,17 @@ class Crosshair(QObject):
""" """
x, y = pos x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y) x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
# # Update coordinate label for item in self.items:
self.coord_label.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})") if isinstance(item, pg.ImageItem):
image = item.image
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{self.precision}g}"
break
# Update coordinate label
self.coord_label.setText(text)
self.coord_label.setPos(x, y) self.coord_label.setPos(x, y)
self.coord_label.setVisible(True) self.coord_label.setVisible(True)
@@ -383,6 +450,9 @@ class Crosshair(QObject):
self.clear_markers() self.clear_markers()
def cleanup(self): def cleanup(self):
if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d)
self.marker_2d = None
self.plot_item.removeItem(self.v_line) self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line) self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label) self.plot_item.removeItem(self.coord_label)
+3 -1
View File
@@ -22,7 +22,9 @@ class EntryValidator:
if entry is None or entry == "": if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description: if entry not in description:
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals") raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
)
return entry return entry
@@ -143,7 +143,7 @@ class DesignerPluginGenerator:
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue # from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.spinner.spinner import SpinnerWidget from bec_widgets.widgets.utility.spinner import SpinnerWidget
generator = DesignerPluginGenerator(SpinnerWidget) generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False) generator.run(validate=False)
+1 -4
View File
@@ -148,10 +148,7 @@ class BECTickItem(BECIndicatorItem):
def cleanup(self) -> None: def cleanup(self) -> None:
"""Cleanup the item""" """Cleanup the item"""
self.remove_from_plot() self.remove_from_plot()
if self.tick_item is not None: self.tick_item = None
self.tick_item.close()
self.tick_item.deleteLater()
self.tick_item = None
class BECArrowItem(BECIndicatorItem): class BECArrowItem(BECIndicatorItem):
+14 -5
View File
@@ -53,7 +53,7 @@ class BECClassInfo:
obj: type obj: type
is_connector: bool = False is_connector: bool = False
is_widget: bool = False is_widget: bool = False
is_top_level: bool = False is_plugin: bool = False
class BECClassContainer: class BECClassContainer:
@@ -88,14 +88,14 @@ class BECClassContainer:
""" """
Get all top-level classes. Get all top-level classes.
""" """
return [info.obj for info in self.collection if info.is_top_level] return [info.obj for info in self.collection if info.is_plugin]
@property @property
def plugins(self): def plugins(self):
""" """
Get all plugins. These are all classes that are on the top level and are widgets. Get all plugins. These are all classes that are on the top level and are widgets.
""" """
return [info.obj for info in self.collection if info.is_widget and info.is_top_level] return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property @property
def widgets(self): def widgets(self):
@@ -109,10 +109,17 @@ class BECClassContainer:
""" """
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from. Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
""" """
return [info.obj for info in self.collection if info.is_top_level and info.is_connector] return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""
Get all classes.
"""
return [info.obj for info in self.collection]
def get_rpc_classes(repo_name: str) -> BECClassContainer: def get_custom_classes(repo_name: str) -> BECClassContainer:
""" """
Get all RPC-enabled classes in the specified repository. Get all RPC-enabled classes in the specified repository.
@@ -153,6 +160,8 @@ def get_rpc_classes(repo_name: str) -> BECClassContainer:
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget) issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
): ):
class_info.is_top_level = True class_info.is_top_level = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info) collection.add_class(class_info)
return collection return collection
+2 -2
View File
@@ -4,7 +4,7 @@ from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_rpc_classes from bec_widgets.utils.plugin_utils import get_custom_classes
if PYSIDE6: if PYSIDE6:
from PySide6.QtUiTools import QUiLoader from PySide6.QtUiTools import QUiLoader
@@ -30,7 +30,7 @@ class UILoader:
def __init__(self, parent=None): def __init__(self, parent=None):
self.parent = parent self.parent = parent
widgets = get_rpc_classes("bec_widgets").top_level_classes widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets} self.custom_widgets = {widget.__name__: widget for widget in widgets}
+101 -5
View File
@@ -1,6 +1,5 @@
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Literal
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
@@ -16,6 +15,8 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class WidgetHandler(ABC): class WidgetHandler(ABC):
"""Abstract base class for all widget handlers.""" """Abstract base class for all widget handlers."""
@@ -28,6 +29,15 @@ class WidgetHandler(ABC):
def set_value(self, widget: QWidget, value): def set_value(self, widget: QWidget, value):
"""Set a value on the widget instance.""" """Set a value on the widget instance."""
def connect_change_signal(self, widget: QWidget, slot):
"""
Connect a change signal from this widget to the given slot.
If the widget type doesn't have a known "value changed" signal, do nothing.
slot: a function accepting two arguments (widget, value)
"""
pass
class LineEditHandler(WidgetHandler): class LineEditHandler(WidgetHandler):
"""Handler for QLineEdit widgets.""" """Handler for QLineEdit widgets."""
@@ -38,6 +48,9 @@ class LineEditHandler(WidgetHandler):
def set_value(self, widget: QLineEdit, value: str) -> None: def set_value(self, widget: QLineEdit, value: str) -> None:
widget.setText(value) widget.setText(value)
def connect_change_signal(self, widget: QLineEdit, slot):
widget.textChanged.connect(lambda text, w=widget: slot(w, text))
class ComboBoxHandler(WidgetHandler): class ComboBoxHandler(WidgetHandler):
"""Handler for QComboBox widgets.""" """Handler for QComboBox widgets."""
@@ -53,6 +66,11 @@ class ComboBoxHandler(WidgetHandler):
if isinstance(value, int): if isinstance(value, int):
widget.setCurrentIndex(value) widget.setCurrentIndex(value)
def connect_change_signal(self, widget: QComboBox, slot):
# currentIndexChanged(int) or currentIndexChanged(str) both possible.
# We use currentIndexChanged(int) for a consistent behavior.
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
class TableWidgetHandler(WidgetHandler): class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets.""" """Handler for QTableWidget widgets."""
@@ -72,6 +90,16 @@ class TableWidgetHandler(WidgetHandler):
item = QTableWidgetItem(str(cell_value)) item = QTableWidgetItem(str(cell_value))
widget.setItem(row, col, item) widget.setItem(row, col, item)
def connect_change_signal(self, widget: QTableWidget, slot):
# If desired, we could connect cellChanged(row, col) and then fetch all data.
# This might be noisy if table is large.
# For demonstration, connect cellChanged to update entire table value.
def on_cell_changed(row, col, w=widget):
val = self.get_value(w)
slot(w, val)
widget.cellChanged.connect(on_cell_changed)
class SpinBoxHandler(WidgetHandler): class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets.""" """Handler for QSpinBox and QDoubleSpinBox widgets."""
@@ -82,6 +110,9 @@ class SpinBoxHandler(WidgetHandler):
def set_value(self, widget, value): def set_value(self, widget, value):
widget.setValue(value) widget.setValue(value)
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
class CheckBoxHandler(WidgetHandler): class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets.""" """Handler for QCheckBox widgets."""
@@ -92,6 +123,22 @@ class CheckBoxHandler(WidgetHandler):
def set_value(self, widget, value): def set_value(self, widget, value):
widget.setChecked(value) widget.setChecked(value)
def connect_change_signal(self, widget: QCheckBox, slot):
widget.toggled.connect(lambda val, w=widget: slot(w, val))
class ToggleSwitchHandler(WidgetHandler):
"""Handler for ToggleSwitch widgets."""
def get_value(self, widget, **kwargs):
return widget.checked
def set_value(self, widget, value):
widget.checked = value
def connect_change_signal(self, widget: ToggleSwitch, slot):
widget.enabled.connect(lambda val, w=widget: slot(w, val))
class LabelHandler(WidgetHandler): class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets.""" """Handler for QLabel widgets."""
@@ -99,12 +146,15 @@ class LabelHandler(WidgetHandler):
def get_value(self, widget, **kwargs): def get_value(self, widget, **kwargs):
return widget.text() return widget.text()
def set_value(self, widget, value): def set_value(self, widget: QLabel, value):
widget.setText(value) widget.setText(value)
# QLabel typically doesn't have user-editable changes. No signal to connect.
# If needed, this can remain empty.
class WidgetIO: class WidgetIO:
"""Public interface for getting and setting values using handler mapping""" """Public interface for getting, setting values and connecting signals using handler mapping"""
_handlers = { _handlers = {
QLineEdit: LineEditHandler, QLineEdit: LineEditHandler,
@@ -114,6 +164,7 @@ class WidgetIO:
QDoubleSpinBox: SpinBoxHandler, QDoubleSpinBox: SpinBoxHandler,
QCheckBox: CheckBoxHandler, QCheckBox: CheckBoxHandler,
QLabel: LabelHandler, QLabel: LabelHandler,
ToggleSwitch: ToggleSwitchHandler,
} }
@staticmethod @staticmethod
@@ -148,6 +199,17 @@ class WidgetIO:
elif not ignore_errors: elif not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}") raise ValueError(f"No handler for widget type: {type(widget)}")
@staticmethod
def connect_widget_change_signal(widget, slot):
"""
Connect the widget's value-changed signal to a generic slot function (widget, value).
This now delegates the logic to the widget's handler.
"""
handler_class = WidgetIO._find_handler(widget)
if handler_class:
handler = handler_class()
handler.connect_change_signal(widget, slot)
@staticmethod @staticmethod
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float): def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
""" """
@@ -309,8 +371,8 @@ class WidgetHierarchy:
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values) WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
# Example application to demonstrate the usage of the functions # Example usage
if __name__ == "__main__": # pragma: no cover def hierarchy_example(): # pragma: no cover
app = QApplication([]) app = QApplication([])
# Create instance of WidgetHierarchy # Create instance of WidgetHierarchy
@@ -365,3 +427,37 @@ if __name__ == "__main__": # pragma: no cover
print(f"Config dict new REDUCED: {config_dict_new_reduced}") print(f"Config dict new REDUCED: {config_dict_new_reduced}")
app.exec() app.exec()
def widget_io_signal_example(): # pragma: no cover
app = QApplication([])
main_widget = QWidget()
layout = QVBoxLayout(main_widget)
line_edit = QLineEdit(main_widget)
combo_box = QComboBox(main_widget)
spin_box = QSpinBox(main_widget)
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
layout.addWidget(line_edit)
layout.addWidget(combo_box)
layout.addWidget(spin_box)
main_widget.show()
def universal_slot(w, val):
print(f"Widget {w.objectName() or w} changed, new value: {val}")
# Connect all supported widgets through their handlers
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
app.exec_()
if __name__ == "__main__": # pragma: no cover
# Change example function to test different scenarios
# hierarchy_example()
widget_io_signal_example()
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from bec_lib import bec_logger
from qtpy.QtCore import QSettings
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
logger = bec_logger.logger
class WidgetStateManager:
"""
A class to manage the state of a widget by saving and loading the state to and from a INI file.
Args:
widget(QWidget): The widget to manage the state for.
"""
def __init__(self, widget):
self.widget = widget
def save_state(self, filename: str = None):
"""
Save the state of the widget to an INI file.
Args:
filename(str): The filename to save the state to.
"""
if not filename:
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)
def load_state(self, filename: str = None):
"""
Load the state of the widget from an INI file.
Args:
filename(str): The filename to load the state from.
"""
if not filename:
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)
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
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.
"""
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if (
name == "objectName"
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored() # can be extended to fine filter
):
continue
value = widget.property(name)
settings.setValue(name, value)
settings.endGroup()
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
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.
"""
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if settings.contains(name):
value = settings.value(name)
widget.setProperty(name, value)
settings.endGroup()
# Recursively process children (only if they aren't skipped)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._load_widget_state_qsettings(child, settings)
def _get_full_widget_name(self, widget: QWidget):
"""
Get the full name of the widget including its parent names.
Args:
widget(QWidget): The widget to get the full name for.
Returns:
str: The full name of the widget.
"""
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
class ExampleApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
self.setObjectName("MainWindow")
self.setWindowTitle("State Manager Example")
layout = QVBoxLayout(self)
# A line edit to store some user text
self.line_edit = QLineEdit(self)
self.line_edit.setObjectName("MyLineEdit")
self.line_edit.setPlaceholderText("Enter some text here...")
layout.addWidget(self.line_edit)
# A spin box to hold a numeric value
self.spin_box = QSpinBox(self)
self.spin_box.setObjectName("MySpinBox")
self.spin_box.setRange(0, 100)
layout.addWidget(self.spin_box)
# A checkbox to hold a boolean value
self.check_box = QCheckBox("Enable feature?", self)
self.check_box.setObjectName("MyCheckBox")
layout.addWidget(self.check_box)
# A checkbox that we want to skip
self.check_box_skip = QCheckBox("Enable feature - skip save?", self)
self.check_box_skip.setProperty("skip_state", True)
self.check_box_skip.setObjectName("MyCheckBoxSkip")
layout.addWidget(self.check_box_skip)
# CREATE A "SIDE PANEL" with nested structure and skip all what is inside
self.side_panel = QWidget(self)
self.side_panel.setObjectName("SidePanel")
self.side_panel.setProperty("skip_settings", True) # skip the ENTIRE panel
layout.addWidget(self.side_panel)
# Put some sub-widgets inside side_panel
panel_layout = QVBoxLayout(self.side_panel)
self.panel_label = QLabel("Label in side panel", self.side_panel)
self.panel_label.setObjectName("PanelLabel")
panel_layout.addWidget(self.panel_label)
self.panel_edit = QLineEdit(self.side_panel)
self.panel_edit.setObjectName("PanelLineEdit")
self.panel_edit.setPlaceholderText("I am inside side panel")
panel_layout.addWidget(self.panel_edit)
self.panel_checkbox = QCheckBox("Enable feature in side panel?", self.side_panel)
self.panel_checkbox.setObjectName("PanelCheckBox")
panel_layout.addWidget(self.panel_checkbox)
# Save/Load buttons
button_layout = QHBoxLayout()
self.save_button = QPushButton("Save State", self)
self.load_button = QPushButton("Load State", self)
button_layout.addWidget(self.save_button)
button_layout.addWidget(self.load_button)
layout.addLayout(button_layout)
# Create the state manager
self.state_manager = WidgetStateManager(self)
# Connect buttons
self.save_button.clicked.connect(lambda: self.state_manager.save_state())
self.load_button.clicked.connect(lambda: self.state_manager.load_state())
if __name__ == "__main__": # pragma: no cover:
import sys
app = QApplication(sys.argv)
w = ExampleApp()
w.show()
sys.exit(app.exec_())
-1
View File
@@ -1 +0,0 @@
@@ -1,25 +1,33 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from bec_lib.logger import bec_logger
from pydantic import Field from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui from qtpy import QtCore, QtGui
from bec_widgets.cli.rpc_wigdet_handler import widget_handler from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
logger = bec_logger.logger
if TYPE_CHECKING: if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class DockConfig(ConnectionConfig): class DockConfig(ConnectionConfig):
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.") widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field( position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
"bottom", description="The position of the dock." "bottom", description="The position of the dock."
) )
parent_dock_area: Optional[str] = Field( parent_dock_area: Optional[str] | None = Field(
None, description="The GUI ID of parent dock area of the dock." None, description="The GUI ID of parent dock area of the dock."
) )
@@ -103,16 +111,17 @@ class BECDock(BECWidget, Dock):
ICON_NAME = "widgets" ICON_NAME = "widgets"
USER_ACCESS = [ USER_ACCESS = [
"_config_dict", "_config_dict",
"_rpc_id", "element_list",
"widget_list", "elements",
"new",
"show",
"hide",
"show_title_bar", "show_title_bar",
"hide_title_bar",
"get_widgets_positions",
"set_title", "set_title",
"add_widget", "hide_title_bar",
"list_eligible_widgets", "available_widgets",
"move_widget", "delete",
"remove_widget", "delete_all",
"remove", "remove",
"attach", "attach",
"detach", "detach",
@@ -121,7 +130,7 @@ class BECDock(BECWidget, Dock):
def __init__( def __init__(
self, self,
parent: QWidget | None = None, parent: QWidget | None = None,
parent_dock_area: QWidget | None = None, parent_dock_area: BECDockArea | None = None,
config: DockConfig | None = None, config: DockConfig | None = None,
name: str | None = None, name: str | None = None,
client=None, client=None,
@@ -129,21 +138,24 @@ class BECDock(BECWidget, Dock):
closable: bool = True, closable: bool = True,
**kwargs, **kwargs,
) -> None: ) -> None:
if config is None: if config is None:
config = DockConfig( config = DockConfig(
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id widget_class=self.__class__.__name__,
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
) )
else: else:
if isinstance(config, dict): if isinstance(config, dict):
config = DockConfig(**config) config = DockConfig(**config)
self.config = config self.config = config
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(
client=client, config=config, gui_id=gui_id, name=name
) # Name was checked and created in BEC Widget
label = CustomDockLabel(text=name, closable=closable) label = CustomDockLabel(text=name, closable=closable)
Dock.__init__(self, name=name, label=label, **kwargs) Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
# Dock.__init__(self, name=name, **kwargs) # Dock.__init__(self, name=name, **kwargs)
self.parent_dock_area = parent_dock_area self.parent_dock_area = parent_dock_area
# Layout Manager # Layout Manager
self.layout_manager = GridLayoutManager(self.layout) self.layout_manager = GridLayoutManager(self.layout)
@@ -173,7 +185,18 @@ class BECDock(BECWidget, Dock):
super().float() super().float()
@property @property
def widget_list(self) -> list[BECWidget]: def elements(self) -> dict[str, BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget._name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
""" """
Get the widgets in the dock. Get the widgets in the dock.
@@ -182,10 +205,6 @@ class BECDock(BECWidget, Dock):
""" """
return self.widgets return self.widgets
@widget_list.setter
def widget_list(self, value: list[BECWidget]):
self.widgets = value
def hide_title_bar(self): def hide_title_bar(self):
""" """
Hide the title bar of the dock. Hide the title bar of the dock.
@@ -194,6 +213,20 @@ class BECDock(BECWidget, Dock):
self.label.hide() self.label.hide()
self.labelHidden = True self.labelHidden = True
def show(self):
"""
Show the dock.
"""
super().show()
self.show_title_bar()
def hide(self):
"""
Hide the dock.
"""
self.hide_title_bar()
super().hide()
def show_title_bar(self): def show_title_bar(self):
""" """
Hide the title bar of the dock. Hide the title bar of the dock.
@@ -211,7 +244,6 @@ class BECDock(BECWidget, Dock):
""" """
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name()) self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
self.setTitle(title) self.setTitle(title)
self._name = title
def get_widgets_positions(self) -> dict: def get_widgets_positions(self) -> dict:
""" """
@@ -222,7 +254,7 @@ class BECDock(BECWidget, Dock):
""" """
return self.layout_manager.get_widgets_positions() return self.layout_manager.get_widgets_positions()
def list_eligible_widgets( def available_widgets(
self, self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets ) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
""" """
@@ -233,20 +265,29 @@ class BECDock(BECWidget, Dock):
""" """
return list(widget_handler.widget_classes.keys()) return list(widget_handler.widget_classes.keys())
def add_widget( def _get_list_of_widget_name_of_parent_dock_area(self):
docks = self.parent_dock_area.panel_list
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
def new(
self, self,
widget: BECWidget | str, widget: BECWidget | str,
row=None, name: str | None = None,
col=0, row: int | None = None,
rowspan=1, col: int = 0,
colspan=1, rowspan: int = 1,
colspan: int = 1,
shift: Literal["down", "up", "left", "right"] = "down", shift: Literal["down", "up", "left", "right"] = "down",
) -> BECWidget: ) -> BECWidget:
""" """
Add a widget to the dock. Add a widget to the dock.
Args: Args:
widget(QWidget): The widget to add. widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
name(str): The name of the widget.
row(int): The row to add the widget to. If None, the widget will be added to the next available row. row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to. col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span. rowspan(int): The number of rows the widget should span.
@@ -254,15 +295,39 @@ class BECDock(BECWidget, Dock):
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied. shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
""" """
if row is None: if row is None:
# row = cast(int, self.layout.rowCount()) # type:ignore
row = self.layout.rowCount() row = self.layout.rowCount()
# row = cast(int, row)
if self.layout_manager.is_position_occupied(row, col): if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row) self.layout_manager.shift_widgets(shift, start_row=row)
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
if name is not None: # Name is provided
if name in existing_widgets_parent_dock:
# pylint: disable=protected-access
raise ValueError(
f"Name {name} must be unique for widgets, but already exists in DockArea "
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
)
else: # Name is not provided
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
name = WidgetContainerUtils.generate_unique_name(
name=widget_class_name, list_of_names=existing_widgets_parent_dock
)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
raise ValueError(f"Widget {widget} can not be added to dock.")
if isinstance(widget, str): if isinstance(widget, str):
widget = widget_handler.create_widget(widget) widget = cast(
BECWidget,
widget_handler.create_widget(widget_type=widget, name=name, parent_dock=self),
)
else: else:
widget = widget widget._name = name # pylint: disable=protected-access
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan) self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
@@ -294,37 +359,72 @@ class BECDock(BECWidget, Dock):
""" """
self.float() self.float()
def remove_widget(self, widget_rpc_id: str):
"""
Remove a widget from the dock.
Args:
widget_rpc_id(str): The ID of the widget to remove.
"""
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
self.layout.removeWidget(widget)
self.config.widgets.pop(widget_rpc_id, None)
widget.close()
def remove(self): def remove(self):
""" """
Remove the dock from the parent dock area. Remove the dock from the parent dock area.
""" """
# self.cleanup() self.parent_dock_area.delete(self._name)
self.parent_dock_area.remove_dock(self.name())
def delete(self, widget_name: str) -> None:
"""
Remove a widget from the dock.
Args:
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget._name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
f"Checking if gui_id was passed as widget_name."
)
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
widget = self.rpc_register.get_rpc_by_id(widget_name)
if widget is None:
logger.warning(
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
)
return
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget._name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
# self._broadcast_update()
def delete_all(self):
"""
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget._name) # pylint: disable=protected-access
def cleanup(self): def cleanup(self):
""" """
Clean up the dock, including all its widgets. Clean up the dock, including all its widgets.
""" """
for widget in self.widgets: # Remove the dock from the parent dock area
if hasattr(widget, "cleanup"): if self.parent_dock_area:
widget.cleanup() self.parent_dock_area.dock_area.docks.pop(self.name(), None)
self.parent_dock_area.config.docks.pop(self.name(), None)
self.delete_all()
self.widgets.clear() self.widgets.clear()
self.label.close() self.label.close()
self.label.deleteLater() self.label.deleteLater()
super().cleanup() super().cleanup()
# def closeEvent(self, event): # pylint: disable=uselsess-parent-delegation
# """Close Event for dock and cleanup.
# This wrapper ensures that the BECWidget close event is triggered.
# If removed, the closeEvent from pyqtgraph will be triggered, which
# is not calling super().closeEvent(event) and will not trigger the BECWidget close event.
# """
# return super().closeEvent(event)
def close(self): def close(self):
""" """
Close the dock area and cleanup. Close the dock area and cleanup.
@@ -332,4 +432,15 @@ class BECDock(BECWidget, Dock):
""" """
self.cleanup() self.cleanup()
super().close() super().close()
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication([])
dock = BECDock(name="dock")
dock.show()
app.exec_()
sys.exit(app.exec_())
@@ -3,12 +3,15 @@ from __future__ import annotations
from typing import Literal, Optional from typing import Literal, Optional
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea from pyqtgraph.dockarea.DockArea import DockArea
from qtpy.QtCore import Qt from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QPainter, QPaintEvent from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import SafeSlot from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import ( from bec_widgets.qt_utils.toolbar import (
ExpandableMenuAction, ExpandableMenuAction,
@@ -18,17 +21,22 @@ from bec_widgets.qt_utils.toolbar import (
) )
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.dock.dock import BECDock, DockConfig from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.image.image_widget import BECImageWidget from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.motor_map.motor_map_widget import BECMotorMapWidget from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox from bec_widgets.widgets.plots_next_gen.image.image import Image
from bec_widgets.widgets.ring_progress_bar.ring_progress_bar import RingProgressBar from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.scan_control.scan_control import ScanControl from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
from bec_widgets.widgets.vscode.vscode import VSCodeEditor from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
class DockAreaConfig(ConnectionConfig): class DockAreaConfig(ConnectionConfig):
@@ -39,18 +47,21 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget): class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [ USER_ACCESS = [
"_config_dict", "new",
"show",
"hide",
"panels", "panels",
"save_state", "panel_list",
"remove_dock", "delete",
"restore_state", "delete_all",
"add_dock", "remove",
"clear_all",
"detach_dock", "detach_dock",
"attach_all", "attach_all",
"_get_all_rpc", "selected_device",
"temp_areas", "save_state",
"restore_state",
] ]
def __init__( def __init__(
@@ -59,6 +70,8 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None, config: DockAreaConfig | None = None,
client=None, client=None,
gui_id: str = None, gui_id: str = None,
name: str | None = None,
**kwargs,
) -> None: ) -> None:
if config is None: if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__) config = DockAreaConfig(widget_class=self.__class__.__name__)
@@ -66,8 +79,9 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict): if isinstance(config, dict):
config = DockAreaConfig(**config) config = DockAreaConfig(**config)
self.config = config self.config = config
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
QWidget.__init__(self, parent=parent) QWidget.__init__(self, parent=parent)
self._parent = parent
self.layout = QVBoxLayout(self) self.layout = QVBoxLayout(self)
self.layout.setSpacing(5) self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setContentsMargins(0, 0, 0, 0)
@@ -81,12 +95,20 @@ class BECDockArea(BECWidget, QWidget):
label="Add Plot ", label="Add Plot ",
actions={ actions={
"waveform": MaterialIconAction( "waveform": MaterialIconAction(
icon_name=BECWaveformWidget.ICON_NAME, icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
tooltip="Add Waveform", ),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=BECMultiWaveformWidget.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True, filled=True,
), ),
"image": MaterialIconAction( "image": MaterialIconAction(
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
), ),
"motor_map": MaterialIconAction( "motor_map": MaterialIconAction(
icon_name=BECMotorMapWidget.ICON_NAME, icon_name=BECMotorMapWidget.ICON_NAME,
@@ -127,6 +149,9 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Circular ProgressBar", tooltip="Add Circular ProgressBar",
filled=True, filled=True,
), ),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
),
}, },
), ),
"separator_2": SeparatorAction(), "separator_2": SeparatorAction(),
@@ -149,38 +174,50 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.addWidget(DarkModeButton(toolbar=True)) self.toolbar.addWidget(DarkModeButton(toolbar=True))
self._hook_toolbar() self._hook_toolbar()
def minimumSizeHint(self):
return QSize(800, 600)
def _hook_toolbar(self): def _hook_toolbar(self):
# Menu Plot # Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect( self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform") lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECMultiWaveformWidget")
) )
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect( self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self.add_dock(widget="BECImageWidget", prefix="image") lambda: self._create_widget_from_toolbar(widget_name="Image")
) )
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect( self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map") lambda: self._create_widget_from_toolbar(widget_name="BECMotorMapWidget")
) )
# Menu Devices # Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect( self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self.add_dock(widget="ScanControl", prefix="scan_control") lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
) )
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect( self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box") lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
) )
# Menu Utils # Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect( self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self.add_dock(widget="BECQueue", prefix="queue") lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
) )
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect( self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self.add_dock(widget="BECStatusBox", prefix="status") lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
) )
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect( self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code") lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
) )
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect( self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar") lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
) )
# Icons # Icons
@@ -188,6 +225,11 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state) self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state) self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
super().paintEvent(event) super().paintEvent(event)
if self._instructions_visible: if self._instructions_visible:
@@ -195,9 +237,20 @@ class BECDockArea(BECWidget, QWidget):
painter.drawText( painter.drawText(
self.rect(), self.rect(),
Qt.AlignCenter, Qt.AlignCenter,
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar", "Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
) )
@property
def selected_device(self) -> str:
gui_id = QApplication.instance().gui_id
auto_update_config = self.client.connector.get(
MessageEndpoints.gui_auto_update_config(gui_id)
)
try:
return auto_update_config.selected_device
except AttributeError:
return None
@property @property
def panels(self) -> dict[str, BECDock]: def panels(self) -> dict[str, BECDock]:
""" """
@@ -209,7 +262,17 @@ class BECDockArea(BECWidget, QWidget):
@panels.setter @panels.setter
def panels(self, value: dict[str, BECDock]): def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value) self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
@property
def panel_list(self) -> list[BECDock]:
"""
Get the docks in the dock area.
Returns:
list: The docks in the dock area.
"""
return list(self.dock_area.docks.values())
@property @property
def temp_areas(self) -> list: def temp_areas(self) -> list:
@@ -253,36 +316,17 @@ class BECDockArea(BECWidget, QWidget):
self.config.docks_state = last_state self.config.docks_state = last_state
return last_state return last_state
def remove_dock(self, name: str):
"""
Remove a dock by name and ensure it is properly closed and cleaned up.
Args:
name(str): The name of the dock to remove.
"""
dock = self.dock_area.docks.pop(name, None)
self.config.docks.pop(name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {name} does not exist.")
@SafeSlot(popup_error=True) @SafeSlot(popup_error=True)
def add_dock( def new(
self, self,
name: str = None, name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None, widget: str | QWidget | None = None,
widget_name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
relative_to: BECDock | None = None, relative_to: BECDock | None = None,
closable: bool = True, closable: bool = True,
floating: bool = False, floating: bool = False,
prefix: str = "dock", row: int | None = None,
widget: str | QWidget | None = None,
row: int = None,
col: int = 0, col: int = 0,
rowspan: int = 1, rowspan: int = 1,
colspan: int = 1, colspan: int = 1,
@@ -292,12 +336,11 @@ class BECDockArea(BECWidget, QWidget):
Args: Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique. name(str): The name of the dock to be displayed and for further references. Has to be unique.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock. position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to. relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable. closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating. floating(bool): Whether the dock is detached after creating.
prefix(str): The prefix for the dock name if no name is provided.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row of the added widget. row(int): The row of the added widget.
col(int): The column of the added widget. col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget. rowspan(int): The rowspan of the added widget.
@@ -306,21 +349,20 @@ class BECDockArea(BECWidget, QWidget):
Returns: Returns:
BECDock: The created dock. BECDock: The created dock.
""" """
if name is None: dock_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
name = WidgetContainerUtils.generate_unique_widget_id( if name is not None: # Name is provided
container=self.dock_area.docks, prefix=prefix if name in dock_names:
) raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
if name in set(self.dock_area.docks.keys()): f"with name: {self._name} and id {self.gui_id}."
raise ValueError(f"Dock with name {name} already exists.") )
else: # Name is not provided
if position is None: name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
position = "bottom"
dock = BECDock(name=name, parent_dock_area=self, closable=closable) dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position dock.config.position = position
self.config.docks[name] = dock.config self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to) self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1: if len(self.dock_area.docks) <= 1:
@@ -329,10 +371,11 @@ class BECDockArea(BECWidget, QWidget):
for dock in self.dock_area.docks.values(): for dock in self.dock_area.docks.values():
dock.show_title_bar() dock.show_title_bar()
if widget is not None and isinstance(widget, str): if widget is not None:
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan) # Check if widget name exists.
elif widget is not None and isinstance(widget, QWidget): dock.new(
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan) widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
)
if ( if (
self._instructions_visible self._instructions_visible
): # TODO still decide how initial instructions should be handled ): # TODO still decide how initial instructions should be handled
@@ -370,46 +413,90 @@ class BECDockArea(BECWidget, QWidget):
Remove a temporary area from the dock area. Remove a temporary area from the dock area.
This is a patched method of pyqtgraph's removeTempArea This is a patched method of pyqtgraph's removeTempArea
""" """
if area not in self.dock_area.tempAreas:
# FIXME add some context for the logging, I am not sure which object is passed.
# It looks like a pyqtgraph.DockArea
logger.info(f"Attempted to remove dock_area, but was not floating.")
return
self.dock_area.tempAreas.remove(area) self.dock_area.tempAreas.remove(area)
area.window().close() area.window().close()
area.window().deleteLater() area.window().deleteLater()
def clear_all(self):
"""
Close all docks and remove all temp areas.
"""
self.attach_all()
for dock in dict(self.dock_area.docks).values():
dock.remove()
self.dock_area.docks.clear()
def cleanup(self): def cleanup(self):
""" """
Cleanup the dock area. Cleanup the dock area.
""" """
self.clear_all() self.delete_all()
self.toolbar.close() self.toolbar.close()
self.toolbar.deleteLater() self.toolbar.deleteLater()
self.dock_area.close() self.dock_area.close()
self.dock_area.deleteLater() self.dock_area.deleteLater()
super().cleanup() super().cleanup()
def close(self): def show(self):
"""Show all windows including floating docks."""
super().show()
for docks in self.panels.values():
if docks.window() is self:
# avoid recursion
continue
docks.window().show()
def hide(self):
"""Hide all windows including floating docks."""
super().hide()
for docks in self.panels.values():
if docks.window() is self:
# avoid recursion
continue
docks.window().hide()
def delete_all(self) -> None:
""" """
Close the dock area and cleanup. Delete all docks.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
""" """
self.cleanup() self.attach_all()
super().close() for dock_name in self.panels.keys():
self.delete(dock_name)
def delete(self, dock_name: str):
"""
Delete a dock by name.
Args:
dock_name(str): The name of the dock to delete.
"""
dock = self.dock_area.docks.pop(dock_name, None)
self.config.docks.pop(dock_name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {dock_name} does not exist.")
# self._broadcast_update()
def remove(self) -> None:
"""Remove the dock area."""
self.close()
if __name__ == "__main__": if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
import sys
from bec_widgets.utils.colors import set_theme from bec_widgets.utils.colors import set_theme
app = QApplication([]) app = QApplication([])
set_theme("auto") set_theme("auto")
dock_area = BECDockArea() dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="Waveform")
dock_area.show() dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
app.exec_() app.exec_()
sys.exit(app.exec_())
@@ -6,7 +6,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dock import BECDockArea from bec_widgets.widgets.containers.dock import BECDockArea
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.dock.dock_area_plugin import BECDockAreaPlugin from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())
@@ -16,10 +16,20 @@ from typeguard import typechecked
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.figure.plots.image.image import BECImageShow, ImageConfig from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import (
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig BECMotorMap,
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig MotorMapConfig,
)
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
BECMultiWaveform,
BECMultiWaveformConfig,
)
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import (
BECWaveform,
Waveform1DConfig,
)
logger = bec_logger.logger logger = bec_logger.logger
@@ -64,16 +74,11 @@ class WidgetHandler:
"BECWaveform": (BECWaveform, Waveform1DConfig), "BECWaveform": (BECWaveform, Waveform1DConfig),
"BECImageShow": (BECImageShow, ImageConfig), "BECImageShow": (BECImageShow, ImageConfig),
"BECMotorMap": (BECMotorMap, MotorMapConfig), "BECMotorMap": (BECMotorMap, MotorMapConfig),
"BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
} }
def create_widget( def create_widget(
self, self, widget_type: str, parent_figure, parent_id: str, config: dict = None, **axis_kwargs
widget_type: str,
widget_id: str,
parent_figure,
parent_id: str,
config: dict = None,
**axis_kwargs,
) -> BECPlotBase: ) -> BECPlotBase:
""" """
Create and configure a widget based on its type. Create and configure a widget based on its type.
@@ -98,7 +103,6 @@ class WidgetHandler:
widget_config_dict = { widget_config_dict = {
"widget_class": widget_class.__name__, "widget_class": widget_class.__name__,
"parent_id": parent_id, "parent_id": parent_id,
"gui_id": widget_id,
**(config if config is not None else {}), **(config if config is not None else {}),
} }
widget_config = config_class(**widget_config_dict) widget_config = config_class(**widget_config_dict)
@@ -134,8 +138,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
"BECWaveform": BECWaveform, "BECWaveform": BECWaveform,
"BECImageShow": BECImageShow, "BECImageShow": BECImageShow,
"BECMotorMap": BECMotorMap, "BECMotorMap": BECMotorMap,
"BECMultiWaveform": BECMultiWaveform,
}
widget_method_map = {
"BECWaveform": "plot",
"BECImageShow": "image",
"BECMotorMap": "motor_map",
"BECMultiWaveform": "multi_waveform",
} }
widget_method_map = {"BECWaveform": "plot", "BECImageShow": "image", "BECMotorMap": "motor_map"}
clean_signal = pyqtSignal() clean_signal = pyqtSignal()
@@ -145,13 +155,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
config: Optional[FigureConfig] = None, config: Optional[FigureConfig] = None,
client=None, client=None,
gui_id: Optional[str] = None, gui_id: Optional[str] = None,
**kwargs,
) -> None: ) -> None:
if config is None: if config is None:
config = FigureConfig(widget_class=self.__class__.__name__) config = FigureConfig(widget_class=self.__class__.__name__)
else: else:
if isinstance(config, dict): if isinstance(config, dict):
config = FigureConfig(**config) config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id) super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
pg.GraphicsLayoutWidget.__init__(self, parent) pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler() self.widget_handler = WidgetHandler()
@@ -445,10 +456,27 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
return motor_map return motor_map
def multi_waveform(
self,
monitor: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
):
multi_waveform = self.subplot_factory(
widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return multi_waveform
multi_waveform.set_monitor(monitor)
return multi_waveform
def subplot_factory( def subplot_factory(
self, self,
widget_type: Literal[ widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap" "BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase", ] = "BECPlotBase",
row: int = None, row: int = None,
col: int = None, col: int = None,
@@ -500,7 +528,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
def add_widget( def add_widget(
self, self,
widget_type: Literal[ widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap" "BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase", ] = "BECPlotBase",
widget_id: str = None, widget_id: str = None,
row: int = None, row: int = None,
@@ -533,15 +561,12 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
widget = self.widget_handler.create_widget( widget = self.widget_handler.create_widget(
widget_type=widget_type, widget_type=widget_type,
widget_id=widget_id,
parent_figure=self, parent_figure=self,
parent_id=self.gui_id, parent_id=self.gui_id,
config=config, config=config,
**axis_kwargs, **axis_kwargs,
) )
# has to be changed manually to ensure unique id, if config is copied from existing widget, the id could be widget_id = widget.gui_id
# used otherwise multiple times
widget.set_gui_id(widget_id)
widget.config.row = row widget.config.row = row
widget.config.col = col widget.config.col = col
@@ -554,6 +579,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
self.config.num_cols = max(self.config.num_cols, col + 1) self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing # Saving config for future referencing
self.config.widgets[widget_id] = widget.config self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget self._widgets[widget_id] = widget
@@ -6,19 +6,22 @@ from typing import Any, Literal, Optional
import numpy as np import numpy as np
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field, ValidationError from pydantic import Field, ValidationError
from qtpy.QtCore import QThread, Slot from qtpy.QtCore import QThread, Slot
from qtpy.QtWidgets import QWidget from qtpy.QtWidgets import QWidget
# from bec_widgets.qt_utils.error_popups import SafeSlot as Slot # from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import EntryValidator from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.figure.plots.image.image_item import BECImageItem, ImageItemConfig from bec_widgets.widgets.containers.figure.plots.image.image_item import (
from bec_widgets.widgets.figure.plots.image.image_processor import ( BECImageItem,
ImageItemConfig,
)
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageProcessor, ImageProcessor,
ImageStats, ImageStats,
ProcessorWorker, ProcessorWorker,
) )
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger logger = bec_logger.logger
@@ -30,6 +33,7 @@ class ImageConfig(SubplotConfig):
) )
# TODO old version will be deprecated
class BECImageShow(BECPlotBase): class BECImageShow(BECPlotBase):
USER_ACCESS = [ USER_ACCESS = [
"_rpc_id", "_rpc_id",
@@ -8,10 +8,13 @@ from bec_lib.logger import bec_logger
from pydantic import Field from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.widgets.figure.plots.image.image_processor import ImageStats, ProcessingConfig from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageStats,
ProcessingConfig,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.image.image import BECImageShow from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
logger = bec_logger.logger logger = bec_logger.logger
@@ -38,6 +41,7 @@ class ImageItemConfig(ConnectionConfig):
) )
# TODO old version will be deprecated
class BECImageItem(BECConnector, pg.ImageItem): class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [ USER_ACCESS = [
"_rpc_id", "_rpc_id",
@@ -69,7 +73,7 @@ class BECImageItem(BECConnector, pg.ImageItem):
self.config = config self.config = config
else: else:
self.config = config self.config = config
super().__init__(config=config, gui_id=gui_id) super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.ImageItem.__init__(self) pg.ImageItem.__init__(self)
self.parent_image = parent_image self.parent_image = parent_image
@@ -7,6 +7,8 @@ import numpy as np
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal, Slot from qtpy.QtCore import QObject, Signal, Slot
# TODO will be deleted
@dataclass @dataclass
class ImageStats: class ImageStats:
@@ -15,8 +15,8 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import Signal, SignalData from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Signal, SignalData
logger = bec_logger.logger logger = bec_logger.logger
@@ -0,0 +1,340 @@
from collections import deque
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class BECMultiWaveformConfig(SubplotConfig):
color_palette: Optional[str] = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: Optional[int] = Field(
200, description="The maximum number of curves to display on the plot."
)
flush_buffer: Optional[bool] = Field(
False, description="Flush the buffer of the plot widget when the curve limit is reached."
)
monitor: Optional[str] = Field(
None, description="The monitor to set for the plot widget."
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
highlight_last_curve: Optional[bool] = Field(
True, description="Highlight the last curve on the plot."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
class BECMultiWaveform(BECPlotBase):
monitor_signal_updated = Signal()
highlighted_curve_index_changed = Signal(int)
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"curves",
"set_monitor",
"set_opacity",
"set_curve_limit",
"set_curve_highlight",
"set_colormap",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_colormap",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"get_all_data",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[BECMultiWaveformConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self.old_scan_id = None
self.scan_id = None
self.monitor = None
self.connected = False
self.current_highlight_index = 0
self._curves = deque()
self.visible_curves = []
self.number_of_visible_curves = 0
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
@property
def curves(self) -> deque:
"""
Get the curves of the plot widget as a deque.
Returns:
deque: Deque of curves.
"""
return self._curves
@curves.setter
def curves(self, value: deque):
self._curves = value
@property
def highlight_last_curve(self) -> bool:
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
return self.config.highlight_last_curve
@highlight_last_curve.setter
def highlight_last_curve(self, value: bool):
self.config.highlight_last_curve = value
def set_monitor(self, monitor: str):
"""
Set the monitor for the plot widget.
Args:
monitor (str): The monitor to set.
"""
self.config.monitor = monitor
self._connect_monitor()
def _connect_monitor(self):
"""
Connect the monitor to the plot widget.
"""
try:
previous_monitor = self.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and self.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
if self.config.monitor and self.connected is False:
self.bec_dispatcher.connect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
)
self.connected = True
self.monitor = self.config.monitor
@Slot(dict, dict)
def on_monitor_1d_update(self, msg: dict, metadata: dict):
"""
Update the plot widget with the monitor data.
Args:
msg(dict): The message data.
metadata(dict): The metadata of the message.
"""
data = msg.get("data", None)
current_scan_id = metadata.get("scan_id", None)
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self.clear_curves()
self.curves.clear()
if self.crosshair:
self.crosshair.clear_markers()
# Always create a new curve and add it
curve = pg.PlotDataItem()
curve.setData(data)
self.plot_item.addItem(curve)
self.curves.append(curve)
# Max Trace and scale colors
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
self.monitor_signal_updated.emit()
@Slot(int)
def set_curve_highlight(self, index: int):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
num_visible_curves = len(self.plot_item.visible_curves)
self.number_of_visible_curves = num_visible_curves
if num_visible_curves == 0:
return # No curves to highlight
if index >= num_visible_curves:
index = num_visible_curves - 1
elif index < 0:
index = num_visible_curves + index
self.current_highlight_index = index
num_colors = num_visible_curves
colors = Colors.evenly_spaced_colors(
colormap=self.config.color_palette, num=num_colors, format="HEX"
)
for i, curve in enumerate(self.plot_item.visible_curves):
curve.setPen()
if i == self.current_highlight_index:
curve.setPen(pg.mkPen(color=colors[i], width=5))
curve.setAlpha(alpha=1, auto=False)
curve.setZValue(1)
else:
curve.setPen(pg.mkPen(color=colors[i], width=1))
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
curve.setZValue(0)
self.highlighted_curve_index_changed.emit(self.current_highlight_index)
@Slot(int)
def set_opacity(self, opacity: int):
"""
Set the opacity of the curve on the plot.
Args:
opacity(int): The opacity of the curve. 0-100.
"""
self.config.opacity = max(0, min(100, opacity))
self.set_curve_highlight(self.current_highlight_index)
@Slot(int, bool)
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
self.config.curve_limit = max_trace
self.config.flush_buffer = flush_buffer
if self.config.curve_limit is None:
self.scale_colors()
return
if self.config.flush_buffer:
# Remove excess curves from the plot and the deque
while len(self.curves) > self.config.curve_limit:
curve = self.curves.popleft()
self.plot_item.removeItem(curve)
else:
# Hide or show curves based on the new max_trace
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
for i, curve in enumerate(self.curves):
if i < len(self.curves) - num_curves_to_show:
curve.hide()
else:
curve.show()
self.scale_colors()
def scale_colors(self):
"""
Scale the colors of the curves based on the current colormap.
"""
if self.config.highlight_last_curve:
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
else:
self.set_curve_highlight(self.current_highlight_index)
def set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
self.config.color_palette = colormap
self.set_curve_highlight(self.current_highlight_index)
def hook_crosshair(self) -> None:
super().hook_crosshair()
if self.crosshair:
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
if self.curves:
self.crosshair.update_highlighted_curve(self.current_highlight_index)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
data = {}
try:
import pandas as pd
except ImportError:
pd = None
if output == "pandas":
logger.warning(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
)
output = "dict"
curve_keys = []
curves_list = list(self.curves)
for i, curve in enumerate(curves_list):
x_data, y_data = curve.getData()
if x_data is not None or y_data is not None:
key = f"curve_{i}"
curve_keys.append(key)
if output == "dict":
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
elif output == "pandas" and pd is not None:
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
if output == "pandas" and pd is not None:
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
return combined_data
return data
def clear_curves(self):
"""
Remove all curves from the plot, excluding crosshair items.
"""
items_to_remove = []
for item in self.plot_item.items:
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
items_to_remove.append(item)
for item in items_to_remove:
self.plot_item.removeItem(item)
def export_to_matplotlib(self):
"""
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
"""
MatplotlibExporter(self.plot_item).export()
@@ -98,10 +98,11 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
config: Optional[SubplotConfig] = None, config: Optional[SubplotConfig] = None,
client=None, client=None,
gui_id: Optional[str] = None, gui_id: Optional[str] = None,
**kwargs,
): ):
if config is None: if config is None:
config = SubplotConfig(widget_class=self.__class__.__name__) config = SubplotConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
pg.GraphicsLayout.__init__(self, parent) pg.GraphicsLayout.__init__(self, parent)
self.figure = parent_figure self.figure = parent_figure
@@ -19,8 +19,8 @@ from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import ( from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import (
BECCurve, BECCurve,
CurveConfig, CurveConfig,
Signal, Signal,
@@ -99,11 +99,17 @@ class BECWaveform(BECPlotBase):
config: Optional[Waveform1DConfig] = None, config: Optional[Waveform1DConfig] = None,
client=None, client=None,
gui_id: Optional[str] = None, gui_id: Optional[str] = None,
**kwargs,
): ):
if config is None: if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__) config = Waveform1DConfig(widget_class=self.__class__.__name__)
super().__init__( super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id parent=parent,
parent_figure=parent_figure,
config=config,
client=client,
gui_id=gui_id,
**kwargs,
) )
self._curves_data = defaultdict(dict) self._curves_data = defaultdict(dict)
@@ -120,6 +126,8 @@ class BECWaveform(BECPlotBase):
"label_suffix": "", "label_suffix": "",
} }
self._slice_index = None
# Scan segment update proxy # Scan segment update proxy
self.proxy_update_plot = pg.SignalProxy( self.proxy_update_plot = pg.SignalProxy(
self.scan_signal_update, rateLimit=25, slot=self._update_scan_curves self.scan_signal_update, rateLimit=25, slot=self._update_scan_curves
@@ -1242,16 +1250,24 @@ class BECWaveform(BECPlotBase):
msg(dict): Message with the async data. msg(dict): Message with the async data.
metadata(dict): Metadata of the message. metadata(dict): Metadata of the message.
""" """
instruction = metadata.get("async_update") y_data = None
for curve in self._curves_data["async"].values(): x_data = None
y_name = curve.config.signals.y.name instruction = metadata.get("async_update", {}).get("type")
max_shape = metadata.get("async_update", {}).get("max_shape", [])
all_async_curves = self._curves_data["async"].values()
# for curve in self._curves_data["async"].values():
for curve in all_async_curves:
y_entry = curve.config.signals.y.entry y_entry = curve.config.signals.y.entry
x_name = self._x_axis_mode["name"] x_name = self._x_axis_mode["name"]
for device, async_data in msg["signals"].items(): for device, async_data in msg["signals"].items():
if device == y_entry: if device == y_entry:
data_plot = async_data["value"] data_plot = async_data["value"]
if instruction == "extend": if instruction == "add":
x_data, y_data = curve.get_data() if len(max_shape) > 1:
if len(data_plot.shape) > 1:
data_plot = data_plot[-1, :]
else:
x_data, y_data = curve.get_data()
if y_data is not None: if y_data is not None:
new_data = np.hstack((y_data, data_plot)) new_data = np.hstack((y_data, data_plot))
else: else:
@@ -1264,6 +1280,18 @@ class BECWaveform(BECPlotBase):
curve.setData(x_data, new_data) curve.setData(x_data, new_data)
else: else:
curve.setData(new_data) curve.setData(new_data)
elif instruction == "add_slice":
current_slice_id = metadata.get("async_update", {}).get("index")
data_plot = async_data["value"]
if current_slice_id != self._slice_index:
self._slice_index = current_slice_id
new_data = data_plot
else:
x_data, y_data = curve.get_data()
new_data = np.hstack((y_data, data_plot))
curve.setData(new_data)
elif instruction == "replace": elif instruction == "replace":
if x_name == "timestamp": if x_name == "timestamp":
x_data = async_data["timestamp"] x_data = async_data["timestamp"]
@@ -1512,6 +1540,10 @@ class BECWaveform(BECPlotBase):
for curve_id in curve_ids_to_remove: for curve_id in curve_ids_to_remove:
self.remove_curve(curve_id) self.remove_curve(curve_id)
def reset(self):
self._slice_index = None
super().reset()
def clear_all(self): def clear_all(self):
sources = list(self._curves_data.keys()) sources = list(self._curves_data.keys())
for source in sources: for source in sources:
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional from typing import TYPE_CHECKING, Literal, Optional
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
@@ -11,7 +11,7 @@ from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from bec_widgets.widgets.figure.plots.waveform import BECWaveform1D from bec_widgets.widgets.containers.figure.plots.waveform import BECWaveform1D
logger = bec_logger.logger logger = bec_logger.logger
@@ -97,7 +97,7 @@ class BECCurve(BECConnector, pg.PlotDataItem):
else: else:
self.config = config self.config = config
# config.widget_class = self.__class__.__name__ # config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id) super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.PlotDataItem.__init__(self, name=name) pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item self.parent_item = parent_item
@@ -0,0 +1,882 @@
import math
import sys
from typing import Dict, Literal, Optional, Set, Tuple, Union
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QSpinBox,
QSplitter,
QVBoxLayout,
QWidget,
)
from typeguard import typechecked
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
class LayoutManagerWidget(QWidget):
"""
A robust layout manager that extends QGridLayout functionality, allowing
users to add/remove widgets, access widgets by coordinates, shift widgets,
and change the layout dynamically with automatic reindexing to keep the grid compact.
Supports adding widgets via QWidget instances or string identifiers referencing the widget handler.
"""
def __init__(self, parent=None, auto_reindex=True):
super().__init__(parent)
self.setObjectName("LayoutManagerWidget")
self.layout = QGridLayout(self)
self.auto_reindex = auto_reindex
# Mapping from widget to its position (row, col, rowspan, colspan)
self.widget_positions: Dict[QWidget, Tuple[int, int, int, int]] = {}
# Mapping from (row, col) to widget
self.position_widgets: Dict[Tuple[int, int], QWidget] = {}
# Keep track of the current position for automatic placement
self.current_row = 0
self.current_col = 0
def add_widget(
self,
widget: QWidget | str,
row: int | None = None,
col: Optional[int] = None,
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> QWidget:
"""
Add a widget to the grid with enhanced shifting capabilities.
Args:
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
row (int, optional): The row to add the widget to. If None, the next available row is used.
col (int, optional): The column to add the widget to. If None, the next available column is used.
rowspan (int): Number of rows the widget spans. Default is 1.
colspan (int): Number of columns the widget spans. Default is 1.
shift_existing (bool): Whether to shift existing widgets if the target position is occupied. Default is True.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. Default is "right".
Returns:
QWidget: The widget that was added.
"""
# Handle widget creation if a BECWidget string identifier is provided
if isinstance(widget, str):
widget = widget_handler.create_widget(widget)
if row is None:
row = self.current_row
if col is None:
col = self.current_col
if (row, col) in self.position_widgets:
if shift_existing:
# Attempt to shift the existing widget in the specified direction
self.shift_widgets(direction=shift_direction, start_row=row, start_col=col)
else:
raise ValueError(f"Position ({row}, {col}) is already occupied.")
# Add the widget to the layout
self.layout.addWidget(widget, row, col, rowspan, colspan)
self.widget_positions[widget] = (row, col, rowspan, colspan)
self.position_widgets[(row, col)] = widget
# Update current position for automatic placement
self.current_col = col + colspan
self.current_row = max(self.current_row, row)
if self.auto_reindex:
self.reindex_grid()
return widget
def add_widget_relative(
self,
widget: QWidget | str,
reference_widget: QWidget,
position: Literal["left", "right", "top", "bottom"],
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> QWidget:
"""
Add a widget relative to an existing widget.
Args:
widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler.
reference_widget (QWidget): The widget relative to which the new widget will be placed.
position (Literal["left", "right", "top", "bottom"]): Position relative to the reference widget.
rowspan (int): Number of rows the widget spans. Default is 1.
colspan (int): Number of columns the widget spans. Default is 1.
shift_existing (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Returns:
QWidget: The widget that was added.
Raises:
ValueError: If the reference widget is not found.
"""
if reference_widget not in self.widget_positions:
raise ValueError("Reference widget not found in layout.")
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
# Determine new widget position based on the specified relative position
if position == "left":
new_row = ref_row
new_col = ref_col - 1
elif position == "right":
new_row = ref_row
new_col = ref_col + ref_colspan
elif position == "top":
new_row = ref_row - 1
new_col = ref_col
elif position == "bottom":
new_row = ref_row + ref_rowspan
new_col = ref_col
else:
raise ValueError("Invalid position. Choose from 'left', 'right', 'top', 'bottom'.")
# Add the widget at the calculated position
return self.add_widget(
widget=widget,
row=new_row,
col=new_col,
rowspan=rowspan,
colspan=colspan,
shift_existing=shift_existing,
shift_direction=shift_direction,
)
def move_widget_by_coords(
self,
current_row: int,
current_col: int,
new_row: int,
new_col: int,
shift: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> None:
"""
Move a widget from (current_row, current_col) to (new_row, new_col).
Args:
current_row (int): Current row of the widget.
current_col (int): Current column of the widget.
new_row (int): Target row.
new_col (int): Target column.
shift (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Raises:
ValueError: If the widget is not found or target position is invalid.
"""
self.move_widget(
old_row=current_row,
old_col=current_col,
new_row=new_row,
new_col=new_col,
shift=shift,
shift_direction=shift_direction,
)
@typechecked
def move_widget_by_object(
self,
widget: QWidget,
new_row: int,
new_col: int,
shift: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> None:
"""
Move a widget to a new position using the widget object.
Args:
widget (QWidget): The widget to move.
new_row (int): Target row.
new_col (int): Target column.
shift (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Raises:
ValueError: If the widget is not found or target position is invalid.
"""
if widget not in self.widget_positions:
raise ValueError("Widget not found in layout.")
old_position = self.widget_positions[widget]
old_row, old_col = old_position[0], old_position[1]
self.move_widget(
old_row=old_row,
old_col=old_col,
new_row=new_row,
new_col=new_col,
shift=shift,
shift_direction=shift_direction,
)
@typechecked
def move_widget(
self,
old_row: int | None = None,
old_col: int | None = None,
new_row: int | None = None,
new_col: int | None = None,
shift: bool = True,
shift_direction: Literal["down", "up", "left", "right"] = "right",
) -> None:
"""
Move a widget to a new position. If the new position is occupied and shift is True,
shift the existing widget to the specified direction.
Args:
old_row (int, optional): The current row of the widget.
old_col (int, optional): The current column of the widget.
new_row (int, optional): The target row to move the widget to.
new_col (int, optional): The target column to move the widget to.
shift (bool): Whether to shift existing widgets if the target position is occupied.
shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets.
Raises:
ValueError: If the widget is not found or target position is invalid.
"""
if new_row is None or new_col is None:
raise ValueError("Must provide both new_row and new_col to move a widget.")
if old_row is None and old_col is None:
raise ValueError(f"No widget found at position ({old_row}, {old_col}).")
widget = self.get_widget(old_row, old_col)
if (new_row, new_col) in self.position_widgets:
if not shift:
raise ValueError(f"Position ({new_row}, {new_col}) is already occupied.")
# Shift the existing widget to make space
self.shift_widgets(
direction=shift_direction,
start_row=new_row if shift_direction in ["down", "up"] else 0,
start_col=new_col if shift_direction in ["left", "right"] else 0,
)
# Proceed to move the widget
self.layout.removeWidget(widget)
old_position = self.widget_positions.pop(widget)
self.position_widgets.pop((old_position[0], old_position[1]))
self.layout.addWidget(widget, new_row, new_col, old_position[2], old_position[3])
self.widget_positions[widget] = (new_row, new_col, old_position[2], old_position[3])
self.position_widgets[(new_row, new_col)] = widget
# Update current_row and current_col for automatic placement if needed
self.current_row = max(self.current_row, new_row)
self.current_col = max(self.current_col, new_col + old_position[3])
if self.auto_reindex:
self.reindex_grid()
@typechecked
def shift_widgets(
self,
direction: Literal["down", "up", "left", "right"],
start_row: int = 0,
start_col: int = 0,
) -> None:
"""
Shift widgets in the grid in the specified direction starting from the given position.
Args:
direction (Literal["down", "up", "left", "right"]): Direction to shift widgets.
start_row (int): Starting row index.
start_col (int): Starting column index.
Raises:
ValueError: If shifting causes widgets to go out of grid boundaries.
"""
shifts = []
positions_to_shift = [(start_row, start_col)]
visited_positions = set()
while positions_to_shift:
row, col = positions_to_shift.pop(0)
if (row, col) in visited_positions:
continue
visited_positions.add((row, col))
widget = self.position_widgets.get((row, col))
if widget is None:
continue # No widget at this position
# Compute new position based on the direction
if direction == "down":
new_row = row + 1
new_col = col
elif direction == "up":
new_row = row - 1
new_col = col
elif direction == "right":
new_row = row
new_col = col + 1
elif direction == "left":
new_row = row
new_col = col - 1
# Check for negative indices
if new_row < 0 or new_col < 0:
raise ValueError("Shifting widgets out of grid boundaries.")
# If the new position is occupied, add it to the positions to shift
if (new_row, new_col) in self.position_widgets:
positions_to_shift.append((new_row, new_col))
shifts.append(
(widget, (row, col), (new_row, new_col), self.widget_positions[widget][2:])
)
# Remove all widgets from their old positions
for widget, (old_row, old_col), _, _ in shifts:
self.layout.removeWidget(widget)
self.position_widgets.pop((old_row, old_col))
# Add widgets to their new positions
for widget, _, (new_row, new_col), (rowspan, colspan) in shifts:
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
self.position_widgets[(new_row, new_col)] = widget
# Update current_row and current_col if needed
self.current_row = max(self.current_row, new_row)
self.current_col = max(self.current_col, new_col + colspan)
def shift_all_widgets(self, direction: Literal["down", "up", "left", "right"]) -> None:
"""
Shift all widgets in the grid in the specified direction to make room and prevent negative indices.
Args:
direction (Literal["down", "up", "left", "right"]): Direction to shift all widgets.
"""
# First, collect all the shifts to perform
shifts = []
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
if direction == "down":
new_row = row + 1
new_col = col
elif direction == "up":
new_row = row - 1
new_col = col
elif direction == "right":
new_row = row
new_col = col + 1
elif direction == "left":
new_row = row
new_col = col - 1
# Check for negative indices
if new_row < 0 or new_col < 0:
raise ValueError("Shifting widgets out of grid boundaries.")
shifts.append((widget, (row, col), (new_row, new_col), (rowspan, colspan)))
# Now perform the shifts
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
self.layout.removeWidget(widget)
self.position_widgets.pop((old_row, old_col))
for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts:
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
self.position_widgets[(new_row, new_col)] = widget
# Update current_row and current_col based on new widget positions
self.current_row = max((pos[0] for pos in self.position_widgets.keys()), default=0)
self.current_col = max((pos[1] for pos in self.position_widgets.keys()), default=0)
def remove(
self,
row: int | None = None,
col: int | None = None,
coordinates: Tuple[int, int] | None = None,
) -> None:
"""
Remove a widget from the layout. Can be removed by widget ID or by coordinates.
Args:
row (int, optional): The row coordinate of the widget to remove.
col (int, optional): The column coordinate of the widget to remove.
coordinates (tuple[int, int], optional): The (row, col) coordinates of the widget to remove.
Raises:
ValueError: If the widget to remove is not found.
"""
if coordinates:
row, col = coordinates
widget = self.get_widget(row, col)
if widget is None:
raise ValueError(f"No widget found at coordinates {coordinates}.")
elif row is not None and col is not None:
widget = self.get_widget(row, col)
if widget is None:
raise ValueError(f"No widget found at position ({row}, {col}).")
else:
raise ValueError(
"Must provide either widget_id, coordinates, or both row and col for removal."
)
self.remove_widget(widget)
def remove_widget(self, widget: QWidget) -> None:
"""
Remove a widget from the grid and reindex the grid to keep it compact.
Args:
widget (QWidget): The widget to remove.
Raises:
ValueError: If the widget is not found in the layout.
"""
if widget not in self.widget_positions:
raise ValueError("Widget not found in layout.")
position = self.widget_positions.pop(widget)
self.position_widgets.pop((position[0], position[1]))
self.layout.removeWidget(widget)
widget.setParent(None) # Remove widget from the parent
widget.deleteLater()
# Reindex the grid to maintain compactness
if self.auto_reindex:
self.reindex_grid()
def get_widget(self, row: int, col: int) -> QWidget | None:
"""
Get the widget at the specified position.
Args:
row (int): The row coordinate.
col (int): The column coordinate.
Returns:
QWidget | None: The widget at the specified position, or None if empty.
"""
return self.position_widgets.get((row, col))
def get_widget_position(self, widget: QWidget) -> Tuple[int, int, int, int] | None:
"""
Get the position of the specified widget.
Args:
widget (QWidget): The widget to query.
Returns:
Tuple[int, int, int, int] | None: The (row, col, rowspan, colspan) tuple, or None if not found.
"""
return self.widget_positions.get(widget)
def change_layout(self, num_rows: int | None = None, num_cols: int | None = None) -> None:
"""
Change the layout to have a certain number of rows and/or columns,
rearranging the widgets accordingly.
If only one of num_rows or num_cols is provided, the other is calculated automatically
based on the number of widgets and the provided constraint.
If both are provided, num_rows is calculated based on num_cols.
Args:
num_rows (int | None): The new maximum number of rows.
num_cols (int | None): The new maximum number of columns.
"""
if num_rows is None and num_cols is None:
return # Nothing to change
total_widgets = len(self.widget_positions)
if num_cols is not None:
# Calculate num_rows based on num_cols
num_rows = math.ceil(total_widgets / num_cols)
elif num_rows is not None:
# Calculate num_cols based on num_rows
num_cols = math.ceil(total_widgets / num_rows)
# Sort widgets by current position (row-major order)
widgets_sorted = sorted(
self.widget_positions.items(),
key=lambda item: (item[1][0], item[1][1]), # Sort by row, then column
)
# Clear the layout without deleting widgets
for widget, _ in widgets_sorted:
self.layout.removeWidget(widget)
# Reset position mappings
self.widget_positions.clear()
self.position_widgets.clear()
# Re-add widgets based on new layout constraints
current_row, current_col = 0, 0
for widget, _ in widgets_sorted:
if current_col >= num_cols:
current_col = 0
current_row += 1
self.layout.addWidget(widget, current_row, current_col, 1, 1)
self.widget_positions[widget] = (current_row, current_col, 1, 1)
self.position_widgets[(current_row, current_col)] = widget
current_col += 1
# Update current_row and current_col for automatic placement
self.current_row = current_row
self.current_col = current_col
# Reindex the grid to ensure compactness
self.reindex_grid()
def clear_layout(self) -> None:
"""
Remove all widgets from the layout without deleting them.
"""
for widget in list(self.widget_positions):
self.layout.removeWidget(widget)
self.position_widgets.pop(
(self.widget_positions[widget][0], self.widget_positions[widget][1])
)
self.widget_positions.pop(widget)
widget.setParent(None) # Optionally hide/remove the widget
self.current_row = 0
self.current_col = 0
def reindex_grid(self) -> None:
"""
Reindex the grid to remove empty rows and columns, ensuring that
widget coordinates are contiguous and start from (0, 0).
"""
# Step 1: Collect all occupied positions
occupied_positions = sorted(self.position_widgets.keys())
if not occupied_positions:
# No widgets to reindex
self.clear_layout()
return
# Step 2: Determine the new mapping by eliminating empty columns and rows
# Find unique rows and columns
unique_rows = sorted(set(pos[0] for pos in occupied_positions))
unique_cols = sorted(set(pos[1] for pos in occupied_positions))
# Create mappings from old to new indices
row_mapping = {old_row: new_row for new_row, old_row in enumerate(unique_rows)}
col_mapping = {old_col: new_col for new_col, old_col in enumerate(unique_cols)}
# Step 3: Collect widgets with their new positions
widgets_with_new_positions = []
for widget, (row, col, rowspan, colspan) in self.widget_positions.items():
new_row = row_mapping[row]
new_col = col_mapping[col]
widgets_with_new_positions.append((widget, new_row, new_col, rowspan, colspan))
# Step 4: Clear the layout and reset mappings
self.clear_layout()
# Reset current_row and current_col
self.current_row = 0
self.current_col = 0
# Step 5: Re-add widgets with new positions
for widget, new_row, new_col, rowspan, colspan in widgets_with_new_positions:
self.layout.addWidget(widget, new_row, new_col, rowspan, colspan)
self.widget_positions[widget] = (new_row, new_col, rowspan, colspan)
self.position_widgets[(new_row, new_col)] = widget
# Update current position for automatic placement
self.current_col = max(self.current_col, new_col + colspan)
self.current_row = max(self.current_row, new_row)
def get_widgets_positions(self) -> Dict[QWidget, Tuple[int, int, int, int]]:
"""
Get the positions of all widgets in the layout.
Returns:
Dict[QWidget, Tuple[int, int, int, int]]: Mapping of widgets to their (row, col, rowspan, colspan).
"""
return self.widget_positions.copy()
def print_all_button_text(self):
"""Debug function to print the text of all QPushButton widgets."""
print("Coordinates - Button Text")
for coord, widget in self.position_widgets.items():
if isinstance(widget, QPushButton):
print(f"{coord} - {widget.text()}")
####################################################################################################
# The following code is for the GUI control panel to interact with the LayoutManagerWidget.
# It is not covered by any tests as it serves only as an example for the LayoutManagerWidget class.
####################################################################################################
class ControlPanel(QWidget): # pragma: no cover
def __init__(self, layout_manager: LayoutManagerWidget):
super().__init__()
self.layout_manager = layout_manager
self.init_ui()
def init_ui(self):
main_layout = QVBoxLayout()
# Add Widget by Coordinates
add_coord_group = QGroupBox("Add Widget by Coordinates")
add_coord_layout = QGridLayout()
add_coord_layout.addWidget(QLabel("Text:"), 0, 0)
self.text_input = QLineEdit()
add_coord_layout.addWidget(self.text_input, 0, 1)
add_coord_layout.addWidget(QLabel("Row:"), 1, 0)
self.row_input = QSpinBox()
self.row_input.setMinimum(0)
add_coord_layout.addWidget(self.row_input, 1, 1)
add_coord_layout.addWidget(QLabel("Column:"), 2, 0)
self.col_input = QSpinBox()
self.col_input.setMinimum(0)
add_coord_layout.addWidget(self.col_input, 2, 1)
self.add_button = QPushButton("Add at Coordinates")
self.add_button.clicked.connect(self.add_at_coordinates)
add_coord_layout.addWidget(self.add_button, 3, 0, 1, 2)
add_coord_group.setLayout(add_coord_layout)
main_layout.addWidget(add_coord_group)
# Add Widget Relative
add_rel_group = QGroupBox("Add Widget Relative to Existing")
add_rel_layout = QGridLayout()
add_rel_layout.addWidget(QLabel("Text:"), 0, 0)
self.rel_text_input = QLineEdit()
add_rel_layout.addWidget(self.rel_text_input, 0, 1)
add_rel_layout.addWidget(QLabel("Reference Widget:"), 1, 0)
self.ref_widget_combo = QComboBox()
add_rel_layout.addWidget(self.ref_widget_combo, 1, 1)
add_rel_layout.addWidget(QLabel("Position:"), 2, 0)
self.position_combo = QComboBox()
self.position_combo.addItems(["left", "right", "top", "bottom"])
add_rel_layout.addWidget(self.position_combo, 2, 1)
self.add_rel_button = QPushButton("Add Relative")
self.add_rel_button.clicked.connect(self.add_relative)
add_rel_layout.addWidget(self.add_rel_button, 3, 0, 1, 2)
add_rel_group.setLayout(add_rel_layout)
main_layout.addWidget(add_rel_group)
# Remove Widget
remove_group = QGroupBox("Remove Widget")
remove_layout = QGridLayout()
remove_layout.addWidget(QLabel("Row:"), 0, 0)
self.remove_row_input = QSpinBox()
self.remove_row_input.setMinimum(0)
remove_layout.addWidget(self.remove_row_input, 0, 1)
remove_layout.addWidget(QLabel("Column:"), 1, 0)
self.remove_col_input = QSpinBox()
self.remove_col_input.setMinimum(0)
remove_layout.addWidget(self.remove_col_input, 1, 1)
self.remove_button = QPushButton("Remove at Coordinates")
self.remove_button.clicked.connect(self.remove_widget)
remove_layout.addWidget(self.remove_button, 2, 0, 1, 2)
remove_group.setLayout(remove_layout)
main_layout.addWidget(remove_group)
# Change Layout
change_layout_group = QGroupBox("Change Layout")
change_layout_layout = QGridLayout()
change_layout_layout.addWidget(QLabel("Number of Rows:"), 0, 0)
self.change_rows_input = QSpinBox()
self.change_rows_input.setMinimum(1)
self.change_rows_input.setValue(1) # Default value
change_layout_layout.addWidget(self.change_rows_input, 0, 1)
change_layout_layout.addWidget(QLabel("Number of Columns:"), 1, 0)
self.change_cols_input = QSpinBox()
self.change_cols_input.setMinimum(1)
self.change_cols_input.setValue(1) # Default value
change_layout_layout.addWidget(self.change_cols_input, 1, 1)
self.change_layout_button = QPushButton("Apply Layout Change")
self.change_layout_button.clicked.connect(self.change_layout)
change_layout_layout.addWidget(self.change_layout_button, 2, 0, 1, 2)
change_layout_group.setLayout(change_layout_layout)
main_layout.addWidget(change_layout_group)
# Remove All Widgets
self.clear_all_button = QPushButton("Clear All Widgets")
self.clear_all_button.clicked.connect(self.clear_all_widgets)
main_layout.addWidget(self.clear_all_button)
# Refresh Reference Widgets and Print Button
self.refresh_button = QPushButton("Refresh Reference Widgets")
self.refresh_button.clicked.connect(self.refresh_references)
self.print_button = QPushButton("Print All Button Text")
self.print_button.clicked.connect(self.layout_manager.print_all_button_text)
main_layout.addWidget(self.refresh_button)
main_layout.addWidget(self.print_button)
main_layout.addStretch()
self.setLayout(main_layout)
self.refresh_references()
def refresh_references(self):
self.ref_widget_combo.clear()
widgets = self.layout_manager.get_widgets_positions()
for widget in widgets:
if isinstance(widget, QPushButton):
self.ref_widget_combo.addItem(widget.text(), widget)
def add_at_coordinates(self):
text = self.text_input.text()
row = self.row_input.value()
col = self.col_input.value()
if not text:
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
return
button = QPushButton(text)
try:
self.layout_manager.add_widget(widget=button, row=row, col=col)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def add_relative(self):
text = self.rel_text_input.text()
ref_index = self.ref_widget_combo.currentIndex()
ref_widget = self.ref_widget_combo.itemData(ref_index)
position = self.position_combo.currentText()
if not text:
QMessageBox.warning(self, "Input Error", "Please enter text for the button.")
return
if ref_widget is None:
QMessageBox.warning(self, "Input Error", "Please select a reference widget.")
return
button = QPushButton(text)
try:
self.layout_manager.add_widget_relative(
widget=button, reference_widget=ref_widget, position=position
)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def remove_widget(self):
row = self.remove_row_input.value()
col = self.remove_col_input.value()
try:
widget = self.layout_manager.get_widget(row, col)
if widget is None:
QMessageBox.warning(self, "Not Found", f"No widget found at ({row}, {col}).")
return
self.layout_manager.remove_widget(widget)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def change_layout(self):
num_rows = self.change_rows_input.value()
num_cols = self.change_cols_input.value()
try:
self.layout_manager.change_layout(num_rows=num_rows, num_cols=num_cols)
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
def clear_all_widgets(self):
reply = QMessageBox.question(
self,
"Confirm Clear",
"Are you sure you want to remove all widgets?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
try:
self.layout_manager.clear_layout()
self.refresh_references()
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Layout Manager Demo")
self.resize(800, 600)
self.init_ui()
def init_ui(self):
central_widget = QWidget()
main_layout = QHBoxLayout()
# Layout Area GroupBox
layout_group = QGroupBox("Layout Area")
layout_group.setMinimumSize(400, 400)
layout_layout = QVBoxLayout()
self.layout_manager = LayoutManagerWidget()
layout_layout.addWidget(self.layout_manager)
layout_group.setLayout(layout_layout)
# Splitter
splitter = QSplitter()
splitter.addWidget(layout_group)
# Control Panel
control_panel = ControlPanel(self.layout_manager)
control_group = QGroupBox("Control Panel")
control_layout = QVBoxLayout()
control_layout.addWidget(control_panel)
control_layout.addStretch()
control_group.setLayout(control_layout)
splitter.addWidget(control_group)
main_layout.addWidget(splitter)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
@@ -0,0 +1,74 @@
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QMainWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
class BECMainWindow(BECWidget, QMainWindow):
def __init__(self, gui_id: str = None, *args, **kwargs):
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
def _dump(self):
"""Return a dictionary with informations about the application state, for use in tests"""
# TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget);
# so, a filtering based on title is applied here, but the solution is to not have those widgets
# as top-level (so for now, a window with no title does not appear in _dump() result)
# NOTE: the main window itself is excluded, since we want to dump dock areas
info = {
tlw.gui_id: {
"title": tlw.windowTitle(),
"visible": tlw.isVisible(),
"class": str(type(tlw)),
}
for tlw in QApplication.instance().topLevelWidgets()
if tlw is not self and tlw.windowTitle()
}
# Add the main window dock area
info[self.centralWidget().gui_id] = {
"title": self.windowTitle(),
"visible": self.isVisible(),
"class": str(type(self.centralWidget())),
}
return info
def new_dock_area(
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
) -> BECDockArea:
"""Create a new dock area.
Args:
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
BECDockArea: The newly created dock area.
"""
rpc_register = RPCRegister()
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
dock_area = BECDockArea(name=name)
dock_area.resize(dock_area.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
dock_area.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
logger.info(f"Existing dock areas: {geometry}")
if geometry is not None:
dock_area.setGeometry(*geometry)
dock_area.show()
return dock_area
def cleanup(self):
super().close()
@@ -4,7 +4,7 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_abort.button_abort import AbortButton from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -9,12 +9,20 @@ from bec_widgets.utils.bec_widget import BECWidget
class AbortButton(BECWidget, QWidget): class AbortButton(BECWidget, QWidget):
"""A button that abort the scan.""" """A button that abort the scan."""
PLUGIN = True
ICON_NAME = "cancel" ICON_NAME = "cancel"
def __init__( def __init__(
self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None self,
parent=None,
client=None,
config=None,
gui_id=None,
toolbar=False,
scan_id=None,
**kwargs,
): ):
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent) QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts() self.get_bec_shortcuts()
@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.button_abort.abort_button_plugin import AbortButtonPlugin from bec_widgets.widgets.control.buttons.button_abort.abort_button_plugin import (
AbortButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(AbortButtonPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(AbortButtonPlugin())
@@ -9,10 +9,11 @@ from bec_widgets.utils.bec_widget import BECWidget
class ResetButton(BECWidget, QWidget): class ResetButton(BECWidget, QWidget):
"""A button that resets the scan queue.""" """A button that resets the scan queue."""
PLUGIN = True
ICON_NAME = "restart_alt" ICON_NAME = "restart_alt"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False): def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent) QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts() self.get_bec_shortcuts()
@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.button_reset.reset_button_plugin import ResetButtonPlugin from bec_widgets.widgets.control.buttons.button_reset.reset_button_plugin import (
ResetButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ResetButtonPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(ResetButtonPlugin())
@@ -4,7 +4,7 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_reset.button_reset import ResetButton from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -9,10 +9,11 @@ from bec_widgets.utils.bec_widget import BECWidget
class ResumeButton(BECWidget, QWidget): class ResumeButton(BECWidget, QWidget):
"""A button that continue scan queue.""" """A button that continue scan queue."""
PLUGIN = True
ICON_NAME = "resume" ICON_NAME = "resume"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False): def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent) QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts() self.get_bec_shortcuts()
@@ -6,7 +6,9 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.button_resume.resume_button_plugin import ResumeButtonPlugin from bec_widgets.widgets.control.buttons.button_resume.resume_button_plugin import (
ResumeButtonPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(ResumeButtonPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(ResumeButtonPlugin())
@@ -4,7 +4,7 @@
from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_resume.button_resume import ResumeButton from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.stop_button.stop_button_plugin import StopButtonPlugin from bec_widgets.widgets.control.buttons.stop_button.stop_button_plugin import StopButtonPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(StopButtonPlugin()) QPyDesignerCustomWidgetCollection.addCustomWidget(StopButtonPlugin())
@@ -9,10 +9,11 @@ from bec_widgets.utils.bec_widget import BECWidget
class StopButton(BECWidget, QWidget): class StopButton(BECWidget, QWidget):
"""A button that stops the current scan.""" """A button that stops the current scan."""
PLUGIN = True
ICON_NAME = "dangerous" ICON_NAME = "dangerous"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False): def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent) QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts() self.get_bec_shortcuts()
@@ -6,7 +6,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.stop_button.stop_button import StopButton from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -9,11 +9,11 @@ from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(BECWidget, QWidget): class PositionIndicator(BECWidget, QWidget):
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"] USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
PLUGIN = True
ICON_NAME = "horizontal_distribute" ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None): def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id) super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent) QWidget.__init__(self, parent=parent)
self.position = 50 self.position = 50
self.min_value = 0 self.min_value = 0
@@ -6,7 +6,9 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.position_indicator.position_indicator import PositionIndicator from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
DOM_XML = """ DOM_XML = """
<ui language='c++'> <ui language='c++'>
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.position_indicator.position_indicator_plugin import ( from bec_widgets.widgets.control.device_control.position_indicator.position_indicator_plugin import (
PositionIndicatorPlugin, PositionIndicatorPlugin,
) )
@@ -0,0 +1,11 @@
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
PositionerBox,
)
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
PositionerBox2D,
)
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
__ALL__ = ["PositionerBox", "PositionerControlLine", "PositionerBox2D"]
@@ -0,0 +1,3 @@
from .positioner_box_base import PositionerBoxBase
__ALL__ = ["PositionerBoxBase"]
@@ -0,0 +1,243 @@
import uuid
from abc import abstractmethod
from ast import Tuple
from typing import Callable, TypedDict
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanQueueMessage
from qtpy.QtWidgets import (
QDialog,
QDoubleSpinBox,
QGroupBox,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
class DeviceUpdateUIComponents(TypedDict):
spinner: SpinnerWidget
setpoint: QLineEdit
readback: QLabel
position_indicator: PositionIndicator
step_size: QDoubleSpinBox
device_box: QGroupBox
stop: QPushButton
tweak_increase: QPushButton
tweak_decrease: QPushButton
class PositionerBoxBase(BECWidget, CompactPopupWidget):
"""Contains some core logic for positioner box widgets"""
current_path = ""
ICON_NAME = "switch_right"
def __init__(self, parent=None, **kwargs):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self._dialog = None
self.get_bec_shortcuts()
def _check_device_is_valid(self, device: str):
"""Check if the device is a positioner
Args:
device (str): The device name
"""
if device not in self.dev:
logger.info(f"Device {device} not found in the device list")
return False
if not isinstance(self.dev[device], Positioner):
logger.info(f"Device {device} is not a positioner")
return False
return True
@abstractmethod
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents: ...
def _init_device(
self,
device: str,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
):
"""Init the device view and readback"""
if self._check_device_is_valid(device):
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
def _stop_device(self, device: str):
"""Stop call"""
request_id = str(uuid.uuid4())
params = {"device": device, "rpc_id": request_id, "func": "stop", "args": [], "kwargs": {}}
msg = ScanQueueMessage(
scan_type="device_rpc",
parameter=params,
queue="emergency",
metadata={"RID": request_id, "response": False},
)
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
# pylint: disable=unused-argument
def _on_device_readback(
self,
device: str,
ui_components: DeviceUpdateUIComponents,
msg_content: dict,
metadata: dict,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
):
signals = msg_content.get("signals", {})
# pylint: disable=protected-access
hinted_signals = self.dev[device]._hints
precision = self.dev[device].precision
spinner = ui_components["spinner"]
position_indicator = ui_components["position_indicator"]
readback = ui_components["readback"]
setpoint = ui_components["setpoint"]
readback_val = None
setpoint_val = None
if len(hinted_signals) == 1:
signal = hinted_signals[0]
readback_val = signals.get(signal, {}).get("value")
for setpoint_signal in ["setpoint", "user_setpoint"]:
setpoint_val = signals.get(f"{device}_{setpoint_signal}", {}).get("value")
if setpoint_val is not None:
break
for moving_signal in ["motor_done_move", "motor_is_moving"]:
is_moving = signals.get(f"{device}_{moving_signal}", {}).get("value")
if is_moving is not None:
break
if is_moving is not None:
spinner.setVisible(True)
if is_moving:
spinner.start()
spinner.setToolTip("Device is moving")
self.set_global_state("warning")
else:
spinner.stop()
spinner.setToolTip("Device is idle")
self.set_global_state("success")
else:
spinner.setVisible(False)
if readback_val is not None:
readback.setText(f"{readback_val:.{precision}f}")
position_emit(readback_val)
if setpoint_val is not None:
setpoint.setText(f"{setpoint_val:.{precision}f}")
limits = self.dev[device].limits
limit_update(limits)
if limits is not None and readback_val is not None and limits[0] != limits[1]:
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
position_indicator.set_value(pos)
def _update_limits_ui(
self, limits: tuple[float, float], position_indicator, setpoint_validator
):
if limits is not None and limits[0] != limits[1]:
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
setpoint_validator.setRange(limits[0], limits[1])
else:
position_indicator.setToolTip("No limits set")
setpoint_validator.setRange(float("-inf"), float("inf"))
def _update_device_ui(self, device: str, ui: DeviceUpdateUIComponents):
ui["device_box"].setTitle(device)
ui["readback"].setToolTip(f"{device} readback")
ui["setpoint"].setToolTip(f"{device} setpoint")
ui["step_size"].setToolTip(f"Step size for {device}")
precision = self.dev[device].precision
if precision is not None:
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
"""Toogle enable/disable on available buttons
Args:
enable (bool): Enable buttons
"""
ui["tweak_increase"].setEnabled(enable)
ui["tweak_decrease"].setEnabled(enable)
ui["stop"].setEnabled(enable)
ui["setpoint"].setEnabled(enable)
ui["step_size"].setEnabled(enable)
def _on_device_change(
self,
old_device: str,
new_device: str,
position_emit: Callable[[float], None],
limit_update: Callable[[tuple[float, float]], None],
on_device_readback: Callable,
ui: DeviceUpdateUIComponents,
):
logger.info(f"Device changed from {old_device} to {new_device}")
self._toggle_enable_buttons(ui, True)
self._init_device(new_device, position_emit, limit_update)
self._swap_readback_signal_connection(on_device_readback, old_device, new_device)
self._update_device_ui(new_device, ui)
def _open_dialog_selection(self, set_positioner: Callable):
def _ods():
"""Open dialog window for positioner selection"""
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
)
line_edit.textChanged.connect(set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
return _ods
@@ -0,0 +1,242 @@
""" Module for a PositionerBox widget to control a positioner device."""
from __future__ import annotations
import os
from bec_lib.device import Positioner
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,
)
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBox(PositionerBoxBase):
"""Simple Widget to control a positioner in box form"""
ui_file = "positioner_box.ui"
dimensions = (234, 224)
PLUGIN = True
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)
def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs):
"""Initialize the PositionerBox widget.
Args:
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, **kwargs)
self._device = ""
self._limits = None
if self.current_path == "":
self.current_path = os.path.dirname(__file__)
self.init_ui()
self.device = device
self._init_device(self.device, self.position_update.emit, self.update_limits)
def init_ui(self):
"""Init the ui"""
self.device_changed.connect(self.on_device_change)
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
self.addWidget(self.ui)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
# fix the size of the device box
db = self.ui.device_box
db.setFixedHeight(self.dimensions[0])
db.setFixedWidth(self.dimensions[1])
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
self.ui.stop.clicked.connect(self.on_stop)
self.ui.stop.setToolTip("Stop")
self.ui.stop.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
self.ui.tweak_right.setToolTip("Tweak right")
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
self.ui.tweak_left.setToolTip("Tweak left")
self.ui.setpoint.returnPressed.connect(self.on_setpoint_change)
self.setpoint_validator = QDoubleValidator()
self.ui.setpoint.setValidator(self.setpoint_validator)
self.ui.spinner_widget.start()
self.ui.tool_button.clicked.connect(self._open_dialog_selection(self.set_positioner))
icon = material_icon(icon_name="edit_note", size=(16, 16), convert_to_pixmap=False)
self.ui.tool_button.setIcon(icon)
def force_update_readback(self):
self._init_device(self.device, self.position_update.emit, self.update_limits)
@SafeProperty(str)
def device(self):
"""Property to set the device"""
return self._device
@device.setter
def device(self, value: str):
"""Setter, checks if device is a string"""
if not value or not isinstance(value, str):
return
if not self._check_device_is_valid(value):
return
old_device = self._device
self._device = value
if not self.label:
self.label = value
self.device_changed.emit(old_device, value)
@SafeProperty(bool)
def hide_device_selection(self):
"""Hide the device selection"""
return not self.ui.tool_button.isVisible()
@hide_device_selection.setter
def hide_device_selection(self, value: bool):
"""Set the device selection visibility"""
self.ui.tool_button.setVisible(not value)
@SafeSlot(bool)
def show_device_selection(self, value: bool):
"""Show the device selection
Args:
value (bool): Show the device selection
"""
self.hide_device_selection = not value
@SafeSlot(str)
def set_positioner(self, positioner: str | Positioner):
"""Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
if isinstance(positioner, Positioner):
positioner = positioner.name
self.device = positioner
@SafeSlot(str, str)
def on_device_change(self, old_device: str, new_device: str):
"""Upon changing the device, a check will be performed if the device is a Positioner.
Args:
old_device (str): The old device name.
new_device (str): The new device name.
"""
if not self._check_device_is_valid(new_device):
return
self._on_device_change(
old_device,
new_device,
self.position_update.emit,
self.update_limits,
self.on_device_readback,
self._device_ui_components(new_device),
)
def _device_ui_components(self, device: str) -> DeviceUpdateUIComponents:
return {
"spinner": self.ui.spinner_widget,
"position_indicator": self.ui.position_indicator,
"readback": self.ui.readback,
"setpoint": self.ui.setpoint,
"step_size": self.ui.step_size,
"device_box": self.ui.device_box,
"stop": self.ui.stop,
"tweak_increase": self.ui.tweak_right,
"tweak_decrease": self.ui.tweak_left,
}
@SafeSlot(dict, dict)
def on_device_readback(self, msg_content: dict, metadata: dict):
"""Callback for device readback.
Args:
msg_content (dict): The message content.
metadata (dict): The message metadata.
"""
self._on_device_readback(
self.device,
self._device_ui_components(self.device),
msg_content,
metadata,
self.position_update.emit,
self.update_limits,
)
def update_limits(self, limits: tuple):
"""Update limits
Args:
limits (tuple): Limits of the positioner
"""
if limits == self._limits:
return
self._limits = limits
self._update_limits_ui(limits, self.ui.position_indicator, self.setpoint_validator)
@SafeSlot()
def on_stop(self):
self._stop_device(self.device)
@property
def step_size(self):
"""Step size for tweak"""
return self.ui.step_size.value()
@SafeSlot()
def on_tweak_right(self):
"""Tweak motor right"""
self.dev[self.device].move(self.step_size, relative=True)
@SafeSlot()
def on_tweak_left(self):
"""Tweak motor left"""
self.dev[self.device].move(-self.step_size, relative=True)
@SafeSlot()
def on_setpoint_change(self):
"""Change the setpoint for the motor"""
self.ui.setpoint.clearFocus()
setpoint = self.ui.setpoint.text()
self.dev[self.device].move(float(setpoint), relative=False)
self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}")
self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}")
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
widget = PositionerBox(device="bpm4i")
widget.show()
sys.exit(app.exec_())

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