1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 01:37:53 +02:00

Compare commits

..

429 Commits

Author SHA1 Message Date
semantic-release
8aba3d975f 1.19.1
Automatically generated by python-semantic-release
2025-02-05 13:49:03 +00:00
5e3289f5bd fix(macos): suppress IMKClient warning on macos 2025-02-05 13:01:40 +01:00
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
f219c6fb57 docs: add docs for LogPanel 2025-01-31 10:10:08 +01:00
f048880277 feat(widget): add LogPanel widget 2025-01-31 10:10:08 +01:00
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
f0c4efefa0 docs: add screenshots for device and signal input 2025-01-30 17:11:44 +01:00
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
e7c97290cd feat(plot_base_next_gen): new type of plot base inherited from QWidget 2025-01-30 16:49:13 +01:00
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
a20935e862 build: pyqt6 support dropped 2025-01-30 15:53:38 +01:00
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
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
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
6df57103bb fix: focus policy and tab order for positioner_box_2d 2025-01-23 13:21:04 +01:00
9a8cc31f6c docs: add documentation for 2D positioner box 2025-01-23 13:21:04 +01:00
d2ffddb6d8 feat(widget): add 2d positioner box widget 2025-01-23 13:21:04 +01:00
3770db51be refactor: move positioner_box logic to base class 2025-01-23 13:21:04 +01:00
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
df961a9b88 fix(cli): server log level info and error 2025-01-22 20:02:00 +01:00
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
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
dfa2908c3d test(error_popups): SafeSlot tests adjusted; tests extended to cover SafeProperty 2025-01-20 10:08:44 +01:00
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
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
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
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
508abfa8a5 fix(toolbar): adjusted to future plot base 2025-01-14 16:47:57 +01:00
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
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
a00d368c25 feat(widget_state_manager): example app added 2025-01-10 16:32:31 +01:00
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
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
e2c7dc98d2 docs: add docs for games/minesweeper 2025-01-09 15:24:00 +01:00
507d46f88b feat(widget): make Minesweeper into BEC widget 2025-01-09 15:24:00 +01:00
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
fb545eebb3 tests(safeslot): wait for panels to be properly rendered 2025-01-09 14:55:31 +01:00
b4a240e463 tests(e2e): wait for the plotting to finish before checking the data 2025-01-09 14:38:58 +01:00
54e64c9f10 feat(widget_io): general change signal for supported widgets 2025-01-06 10:28:16 +01:00
1c8b06cbe6 refactor(rpc,client_utils): minor cleanup and type hint improvements 2024-12-23 15:59:10 +01:00
52c5286d64 fix: do not display error popup if command is executed via RPC 2024-12-23 15:59:10 +01:00
c405421db9 fix: use generator exec feature of BEC Connector to remove the AutoUpdate thread+queue 2024-12-23 15:59:10 +01:00
0ff0c06bd1 feat: add test for BECGuiClient features .new, .delete, .show, .hide, .close 2024-12-23 15:59:10 +01:00
955cc64257 fix: tests: rename fixtures and add 'connected_client_gui_obj' 2024-12-23 15:59:10 +01:00
09cb08a233 fix: prevent top-level dock areas to be destroyed with [X] button 2024-12-23 15:59:10 +01:00
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
1b0382524f fix: simplify AutoUpdate code thanks to threadpool executor in BEC Connector 2024-12-23 15:59:10 +01:00
92b802021f feat: add '.delete()' method to BECDockArea, make main window undeletable 2024-12-23 15:59:10 +01:00
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
42fd78df40 fix: remove useless class member 2024-12-23 15:59:10 +01:00
271a4a24e7 fix: determine default figure since the beginning 2024-12-23 15:59:10 +01:00
1b03ded906 fix: prevent infinite recursion in show/hide methods 2024-12-23 15:59:10 +01:00
bde5618699 feat: add "new()" command to create new dock area windows from client 2024-12-23 15:59:10 +01:00
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
2742a3c6cf fix: set minimum size hint on BECDockArea 2024-12-23 15:59:10 +01:00
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
bdb25206d9 fix: use specified timeout in _run_rpc 2024-12-23 15:59:10 +01:00
bd5414288c build: fixed pytest bec dependency 2024-12-20 18:13:00 +01:00
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
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
ff654b56ae test(collapsible_panel_manager): fixture changed to not use .show() 2024-12-11 15:24:59 +01:00
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
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
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
9b95b5d616 test(side_panel): tests added 2024-12-10 11:42:46 +01:00
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
9370351abb test(modular_toolbar): tests added 2024-12-09 21:10:18 +01:00
a55134c3bf feat(modular_toolbar): material icons can be added/removed/hide/show/update dynamically 2024-12-09 20:56:03 +01:00
5fdb2325ae feat(modular_toolbar): orientation setting 2024-12-09 15:04:59 +01:00
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
da579b6d21 fix(tests): add test for Console widget 2024-12-02 14:44:29 +01:00
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
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
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
31d87036c9 feat: '._auto_updates_enabled' attribute can be used to activate auto updates installation in BECDockArea 2024-11-27 11:44:03 +01:00
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
2fe7f5e151 fix(server): use dock area by default 2024-11-27 11:44:03 +01:00
3ba0b1daf5 feat: add rpc_id member to client objects 2024-11-27 11:44:03 +01:00
e68e2b5978 feat(client): add show()/hide() methods to "gui" object 2024-11-27 11:44:03 +01:00
daf6ea0159 feat(server): add main window, with proper gui_id derived from given id 2024-11-27 11:44:03 +01:00
f80ec33ae5 feat: add main window container widget 2024-11-27 11:44:03 +01:00
c27d058b01 fix(rpc): gui hide/show also hide/show all floating docks 2024-11-27 11:44:03 +01:00
96e255e4ef fix: do not quit automatically when last window is "closed"
Qt confuses closed and hidden
2024-11-27 11:44:03 +01:00
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
2047e484d5 feat: asynchronous .start() for GUI 2024-11-27 11:44:03 +01:00
1f71d8e5ed feat: do not take focus when GUI is loaded 2024-11-25 08:16:10 +01:00
1f60fec720 feat: add '--hide' argument to BEC GUI server 2024-11-25 08:16:10 +01:00
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
e71e3b2956 fix(alignment_1d): fix imports after widget module refactor 2024-11-21 16:39:10 +01:00
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
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
aab0229a40 refactor(widgets): widget module structure reorganised 2024-11-14 14:20:20 +01:00
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
0cd85ed9fa fix(crosshair): crosshair adapted for multi waveform widget 2024-11-12 16:19:42 +01:00
42d4f182f7 docs(multi_waveform): docs added 2024-11-12 16:19:42 +01:00
f3a39a69e2 feat(multi-waveform): new widget added 2024-11-12 16:19:42 +01:00
semantic-release
ec39dae273 1.4.1
Automatically generated by python-semantic-release
2024-11-12 13:46:09 +00:00
8e5c0ad8c8 fix(positioner_box): adjusted default signals 2024-11-12 14:36:38 +01:00
semantic-release
bf0b49b863 1.4.0
Automatically generated by python-semantic-release
2024-11-11 14:19:33 +00:00
11e5937ae0 fix(crosshair): label of coordinates of TextItem displays numbers in general format 2024-11-11 15:09:55 +01:00
4f31ea655c fix(crosshair): label of coordinates of TextItem is updated according to the current theme of qapp 2024-11-11 15:09:55 +01:00
64df805a9e test(crosshair): tests extended 2024-11-11 15:09:55 +01:00
035136d517 feat(crosshair): TextItem to display crosshair coordinates 2024-11-11 15:09:55 +01:00
b2eb71aae0 fix(crosshair): log is separately scaled for backend logic and for signal emit 2024-11-11 15:09:55 +01:00
semantic-release
1e6659c379 1.3.3
Automatically generated by python-semantic-release
2024-11-07 23:02:04 +00:00
5fabd4bea9 fix(scan_control): DeviceLineEdit kwargs readings changed to get name of the positioner 2024-11-07 16:47:42 +01:00
4f0693cae3 docs: update outdated text in docs 2024-11-07 12:49:36 +01:00
semantic-release
ba76d6bb86 1.3.2
Automatically generated by python-semantic-release
2024-11-05 14:53:05 +00:00
2304c9f849 fix(plot_base): legend text color is changed when changing dark-light theme 2024-11-05 10:37:53 +01:00
c6e48ec1fe build: PySide6 version fixed 6.7.2 2024-11-04 14:41:43 +01:00
semantic-release
f837129023 1.3.1
Automatically generated by python-semantic-release
2024-10-31 14:37:23 +00:00
940ee6552c fix(ophyd_kind_util): Kind enums are imported from the bec widget util class 2024-10-31 12:26:10 +01:00
semantic-release
86b60b4aed 1.3.0
Automatically generated by python-semantic-release
2024-10-30 13:19:18 +00:00
14dd8c5b29 fix(colors): extend color map validation for matplotlib and colorcet maps (if available) 2024-10-28 17:17:03 +01:00
b039933405 feat(colormap_button): colormap button with menu to select colormap filtered by the colormap type 2024-10-28 13:48:56 +01:00
semantic-release
d8c80293c7 1.2.0
Automatically generated by python-semantic-release
2024-10-25 17:17:49 +00:00
40c9fea35f feat(colors): evenly spaced color generation + new golden ratio calculation 2024-10-25 19:08:13 +02:00
5d4b86e1c6 refactor: add bec_lib version to statusbox 2024-10-25 16:12:06 +02:00
semantic-release
5681c0cbd1 1.1.0
Automatically generated by python-semantic-release
2024-10-25 08:19:34 +00:00
91959e82de refactor: do not flush selection upon receiving config update; allow widgetIO to receive kwargs to be able to use get_value to receive string instead of int for QComboBox 2024-10-24 18:09:18 +02:00
5eb15b785f refactor: allow to set selection in DeviceInput; automatic update of selection on device config update; cleanup 2024-10-24 13:38:26 +02:00
6fb20552ff refactor: cleanup, added device_signal for signal inputs 2024-10-24 09:21:32 +02:00
0350833f36 feat: add filter i/o utility class 2024-10-22 16:56:16 +02:00
acb79020d4 test(scan_control): tests added for grid_scan to ensure scan_args signal validity 2024-10-22 16:05:14 +02:00
semantic-release
9c6ba6ae73 1.0.2
Automatically generated by python-semantic-release
2024-10-22 13:34:16 +00:00
4f5448cf51 fix(scan_control): scan args signal fixed to emit list instead of hardcoded structure 2024-10-22 15:04:23 +02:00
semantic-release
6f0182115f 1.0.1
Automatically generated by python-semantic-release
2024-10-22 08:47:29 +00:00
7469c892c8 fix(waveform): added support for live_data and data access 2024-10-18 17:10:53 +02:00
semantic-release
cb45527f3e 1.0.0
Automatically generated by python-semantic-release
2024-10-18 09:48:29 +00:00
f9a889fc6d fix(crosshair): downsample clear markers 2024-10-18 11:32:12 +02:00
2ab12ed60a feat!: ability to disable scatter from waveform & compatible crosshair with down sampling 2024-10-18 11:32:12 +02:00
semantic-release
98c68e9ff4 0.119.0
Automatically generated by python-semantic-release
2024-10-17 15:09:10 +00:00
19f4e407e0 fix: fix syntax due to change of api for simulated devices 2024-10-17 16:07:11 +02:00
a23841b255 fix: remove wrongly scoped test 2024-10-17 16:07:11 +02:00
6982711fea fix: rename 'compact' property -> 'compact_view' 2024-10-17 16:07:11 +02:00
0015f0e2d6 fix: Alignment 1D update, make app window a main window (in .ui file) 2024-10-17 16:07:11 +02:00
af9655de0c feat: new PositionerGroup widget 2024-10-17 16:07:11 +02:00
e4121a01cb feat: add 'expand_popup' property to CompactPopupWidget
This property tells if expand should show a popup (by default), or
if the widget should expand in-place
2024-10-17 16:07:11 +02:00
a69d2870e2 refactor: redesign of scan selection and scan control boxes 2024-10-17 16:07:07 +02:00
e3d0a7bbf9 refactor: move add/remove bundle to scan group box 2024-10-17 09:29:55 +02:00
523cc43572 fix: set (Minimum, Fixed) size policy on Stop button 2024-10-17 09:29:55 +02:00
261578796f feat: PositionerBox with a popup view 2024-10-17 09:29:55 +02:00
0b9b1a3c89 feat: emit 'device_selected' and 'scan_axis' from scan control widget 2024-10-14 16:45:26 +02:00
9801d2769e feat: new 'device_selected' signals to ScanControl, ScanGroupBox, DeviceLineEdit 2024-10-14 16:45:26 +02:00
semantic-release
dfccf97a99 0.118.0
Automatically generated by python-semantic-release
2024-10-13 14:18:42 +00:00
9ef1d1c9ac feat(image): image widget can take data from monitor_1d endpoint 2024-10-13 16:13:53 +02:00
b23695167a docs(sphinx-build): adjusted pyside verion 2024-10-11 17:36:24 +02:00
semantic-release
92cc808d65 0.117.1
Automatically generated by python-semantic-release
2024-10-11 15:27:05 +00:00
3a22392780 fix(FPS): qtimer cleanup leaking 2024-10-11 17:17:50 +02:00
f5f1f6c304 feature(vscode): added support for vscode instructions 2024-10-11 15:36:56 +02:00
923867947f feature(vscode): support for controlling vscode from widgets 2024-10-11 15:36:56 +02:00
semantic-release
91260bb579 0.117.0
Automatically generated by python-semantic-release
2024-10-11 10:29:41 +00:00
8dc892df0a tests(plot_base): tests extended 2024-10-11 12:17:17 +02:00
8c5ef26843 feat(utils): FPS counter utility based on the viewBox updates, integrated to waveform and image widget 2024-10-11 09:52:48 +02:00
semantic-release
b681b13a33 0.116.0
Automatically generated by python-semantic-release
2024-10-11 07:17:54 +00:00
499b6b9a12 feat: UI changes to have top toolbar with compact popup widgets (fix issue #360) 2024-10-11 09:08:37 +02:00
94ce92f5b0 feat: adapt BECQueue and BECStatusBox widgets to use CompactPopupWidget 2024-10-11 09:08:37 +02:00
49268e3829 feat: add 'CompactPopupWidget' container widget
Makes it easy to write widgets which can have a compact
representation with LED-like global state indicator,
with the possibility to display a popup dialog with more
complete UI
2024-10-11 09:08:37 +02:00
908dbc1760 build: fix PySide6 to 6.7.2 2024-10-10 22:42:16 +02:00
semantic-release
d7e6506a27 0.115.0
Automatically generated by python-semantic-release
2024-10-08 09:48:59 +00:00
c5e9ed6e42 fix: make Alignment1D a MainWindow as it is an application 2024-10-08 11:39:43 +02:00
b207e45a67 fix: adjust bec_qthemes dependency 2024-10-08 11:39:43 +02:00
8bf4842788 feat: add bec-app script to launch applications 2024-10-08 11:39:43 +02:00
semantic-release
49b9bfc9d3 0.114.0
Automatically generated by python-semantic-release
2024-10-02 20:32:01 +00:00
04cfb1edf1 fix: prevent exception when empty string updates are coming from widget 2024-10-02 16:17:24 +02:00
efa276358b fix: use new 'scan_axis' signal, to set_x and select x axis on waveform
Fixes #361, do not try to change x axis when not permitted
2024-10-02 16:17:24 +02:00
f084e2514b feat: new 'scan_axis' signal
Signal is emitted before "scan_started", to inform about scan positioner
and (start, stop) positions. In case of multiple bundles, the signal
is emitted multiple times.
2024-10-02 16:17:24 +02:00
semantic-release
7cd0b3630e 0.113.0
Automatically generated by python-semantic-release
2024-10-02 11:47:57 +00:00
dc0c825fd5 test: add tests for scan_status_callback 2024-10-01 22:16:16 +02:00
1dcfeb6cfc feat : Add bec_signal_proxy to handle signals with option to unblock them manually. 2024-10-01 22:16:16 +02:00
f554f3c167 refactor: various minor improvements for the alignment gui 2024-10-01 22:16:16 +02:00
0f9953e8fd fix: add is_log checks and functionality to plot_indicator_items 2024-10-01 22:16:16 +02:00
63c24f97a3 feat: add first draft for alignment_1d GUI 2024-10-01 22:16:16 +02:00
efe90eb163 refactor: allow hiding of arg/kwarg boxes 2024-10-01 22:16:16 +02:00
281cb27d8b feat: add move to position button to lmfit dialog 2024-10-01 22:16:16 +02:00
5c740371d8 refactor: add proxy to waveform to limit the dap_request frequency 2024-10-01 22:16:16 +02:00
28ee3856be refactor: update dap_model also if x and y axis are selected 2024-10-01 22:16:16 +02:00
7cc0726398 refactor: linear_region_selector accepts log_x data 2024-10-01 22:16:16 +02:00
e039304fd3 refactor: use accent colors for bec_status_box icons; closes #338 2024-09-26 12:07:33 +02:00
semantic-release
6fa7ca8f09 0.112.1
Automatically generated by python-semantic-release
2024-09-19 09:05:41 +00:00
b2f7d3c5f3 fix: test e2e dap wait_for_fit 2024-09-19 09:30:26 +02:00
e3b5e338bf docs(dap_combo_box): updated screenshot 2024-09-18 14:15:06 +02:00
c8e614b575 docs(device_box): updated screenshot 2024-09-18 14:00:10 +02:00
semantic-release
8e44ca1ad0 0.112.0
Automatically generated by python-semantic-release
2024-09-17 08:13:25 +00:00
286ad7196b feat: console: various improvements, auto-adapt rows to widget size, Qt Designer plugin 2024-09-17 10:08:49 +02:00
semantic-release
adef25f4e2 0.111.0
Automatically generated by python-semantic-release
2024-09-17 04:41:08 +00:00
60f7d54e2b docs(position_indicator): updated position indicator documentation and added designer properties 2024-09-16 16:56:58 +02:00
dd932dd8f3 fix(position_indicator): fixed user access 2024-09-16 16:56:58 +02:00
d3c1a1b2ed fix(generate_cli): fixed type annotations 2024-09-16 16:56:58 +02:00
7ea4a482e7 fix(positioner_box): visual improvements to the positioner_box and positioner_control_line 2024-09-16 13:34:39 +02:00
9045323049 fix(palette viewer): fixed background for tool tip 2024-09-14 18:57:50 +02:00
d15b22250f feat(position_indicator): improved design and added more customization options 2024-09-14 18:33:00 +02:00
semantic-release
5557bfe717 0.110.0
Automatically generated by python-semantic-release
2024-09-12 08:28:50 +00:00
a8576c164c feat(palette_viewer): added widget to display the current palette and accent colors 2024-09-12 08:58:54 +02:00
semantic-release
f5807ec5cd 0.109.1
Automatically generated by python-semantic-release
2024-09-09 15:50:30 +00:00
b0d786b991 fix: refactor textbox widget, remove inheritance, adhere to bec style; closes #324 2024-09-09 17:41:27 +02:00
semantic-release
774044d2a7 0.109.0
Automatically generated by python-semantic-release
2024-09-06 17:30:40 +00:00
84a59f70ee feat(accent colors): added helper function to get all accent colors 2024-09-06 19:26:17 +02:00
de303f0227 fix(theme): fixed theme access for themecontainer 2024-09-06 19:26:17 +02:00
semantic-release
cb2131b1de 0.108.0
Automatically generated by python-semantic-release
2024-09-06 15:18:45 +00:00
7d07cea946 docs(progressbar): added docs 2024-09-06 17:09:45 +02:00
f6d1d0bbe3 feat(progressbar): added bec progressbar 2024-09-06 17:09:45 +02:00
a52182dca9 feat(generate_cli): added support for property and qproperty setter 2024-09-06 17:09:45 +02:00
semantic-release
6731b655e7 0.107.0
Automatically generated by python-semantic-release
2024-09-06 13:34:20 +00:00
bd126dddbb refactor: change style to bec_accent_colors 2024-09-06 15:11:56 +02:00
e6976dc151 docs: extend waveform docs 2024-09-06 12:46:35 +02:00
b1aff6d791 test: add tests, including extension to end-2-end test 2024-09-06 12:46:35 +02:00
7bdca84314 feat: add roi select for dap, allow automatic clear curves on plot request 2024-09-06 12:46:35 +02:00
semantic-release
6b3ea0101e 0.106.0
Automatically generated by python-semantic-release
2024-09-05 12:52:33 +00:00
06d7741622 feat(plot_base): toggle to switch outer axes for plotting widgets 2024-09-05 14:43:20 +02:00
6b15abcc73 test: fix tests 2024-09-04 17:59:36 +02:00
998a745133 refactor: use DAPComboBox in curve_dialog selection 2024-09-04 17:18:40 +02:00
semantic-release
3c519461ec 0.105.0
Automatically generated by python-semantic-release
2024-09-04 14:40:53 +00:00
0fd5cee776 refactor: cleanup and renaming of slot/signals 2024-09-04 16:31:44 +02:00
cc691d4039 feat: add dap_combobox 2024-09-04 16:31:44 +02:00
3a5d7d0796 refactor(logger): changed prints to logger calls 2024-09-04 16:26:13 +02:00
semantic-release
814c823875 0.104.0
Automatically generated by python-semantic-release
2024-09-04 14:25:02 +00:00
90479167fb fix(scan_control): SafeSlot applied to run_scan to avoid faulty scan requests 2024-09-04 16:15:56 +02:00
730e25fd3a docs(scan_control): docs extended 2024-09-04 16:15:56 +02:00
b07e67715c test(scan_control): tests extended for getting kwargs between scan switching and getting parameters from redis 2024-09-04 16:15:56 +02:00
85dcbdaa88 refactor(scan_control): scan control layout adjusted 2024-09-04 16:15:56 +02:00
ec3bc8b519 fix(scan_control): scan parameters can be loaded from the last executed scan from redis 2024-09-04 16:15:56 +02:00
2cd9c7f585 fix(toggle): state can be determined with the widget initialisation 2024-09-04 16:15:56 +02:00
d28f9b04c4 feat(scan_control): scan control remember the previously set parameters and shares kwarg settings across scans 2024-09-04 16:15:56 +02:00
fe8dc55eb1 refactor(scan_control): basic pydantic config added 2024-09-04 16:15:56 +02:00
26920f8482 test(conftest): only run cleanup checks if test passed 2024-09-04 14:34:08 +02:00
semantic-release
8a354690c9 0.103.0
Automatically generated by python-semantic-release
2024-09-04 12:33:15 +00:00
d5eb30cd7d test(webview): fixed tests after refactoring 2024-09-04 13:21:59 +02:00
52da835803 feat(vscode): open vscode on a free port 2024-09-04 13:21:00 +02:00
9be19d4abe feat(website): added method to wait until the webpage is loaded 2024-09-04 13:21:00 +02:00
9866075100 fix(theme): fixed segfault for webengineview for auto updates 2024-09-04 13:21:00 +02:00
158c19eda7 ci: prefill variables for manual pipeline start 2024-09-04 10:02:57 +02:00
39f98ec223 test(vscode): popen call does not have to be the only one 2024-09-04 09:42:38 +02:00
semantic-release
e12a85feaa 0.102.0
Automatically generated by python-semantic-release
2024-09-04 05:55:14 +00:00
047aa26a60 docs(buttons): buttons section of docs split to appearance and queue buttons 2024-09-04 07:45:43 +02:00
9dd43aa1fd fix(queue_reset_button): queue reset has to be confirmed with msgBox 2024-09-04 07:45:43 +02:00
0d7c10e670 feat(queue): BECQueue controls extended with Resume, Stop, Abort, Reset buttons 2024-09-04 07:45:43 +02:00
df5eff3147 refactor(tests): positioner box test changed to use create_widget fixture 2024-09-03 13:39:30 +02:00
18d8561c96 docs(tests): added tests tutorial for widget 2024-09-03 13:32:41 +02:00
semantic-release
103410d4c7 0.101.0
Automatically generated by python-semantic-release
2024-09-02 11:58:55 +00:00
61ecf491e5 refactor: add docs, cleanup 2024-09-02 13:12:59 +02:00
9781b77de2 feat: add Dap dialog widget 2024-09-01 20:57:46 +02:00
semantic-release
162e0ae78b 0.100.0
Automatically generated by python-semantic-release
2024-09-01 08:14:47 +00:00
99d5e8e71c docs(becwidget): improvements to the bec widget base class docs; fixed type hint import for sphinx 2024-08-31 21:42:08 +02:00
6c1f89ad39 fix(pyqt slot): removed slot decorator to avoid problems with pyqt6 2024-08-31 14:51:12 +02:00
7fb938a850 feat(theme): added theme handler to bec widget base class; added tests 2024-08-31 14:32:38 +02:00
semantic-release
08c3d7d175 0.99.15
Automatically generated by python-semantic-release
2024-08-31 09:14:46 +00:00
af23e74f71 fix(theme): update pg axes on theme update 2024-08-31 11:11:13 +02:00
0bf1cf9b8a fix(positioner_box): fixed positioner box dialog; added test; closes #332 2024-08-31 09:45:10 +02:00
semantic-release
6dd64dd8e1 0.99.14
Automatically generated by python-semantic-release
2024-08-30 14:13:56 +00:00
99a98de8a3 fix(color_button): signal and slot added for selecting color and for emitting color after change 2024-08-30 16:03:22 +02:00
3c0e501c56 fix(color_button): inheritance changed to QWidget 2024-08-30 16:03:22 +02:00
semantic-release
9d76d8bf6c 0.99.13
Automatically generated by python-semantic-release
2024-08-30 11:36:36 +00:00
a3110d9814 fix(dark mode button): fixed dark mode button state for external updates, including auto 2024-08-30 10:42:13 +02:00
ec9c8f2963 docs: minor updates to the widget tutorial 2024-08-29 16:43:30 +02:00
b32ced85ff docs(widget tutorial): step by step guide added 2024-08-29 16:43:30 +02:00
semantic-release
d0e5643d4f 0.99.12
Automatically generated by python-semantic-release
2024-08-29 13:20:39 +00:00
2efd48736c fix(toolbar): widget action added 2024-08-29 15:17:32 +02:00
6ed1efc6af fix(reset_button): reset button added 2024-08-29 15:03:42 +02:00
a568633c32 fix(abort_button): abort button added; some minor fixes 2024-08-29 14:14:32 +02:00
semantic-release
6a919be88f 0.99.11
Automatically generated by python-semantic-release
2024-08-29 11:45:18 +00:00
8be8295b2b fix(resume_button): resume button added 2024-08-29 13:36:32 +02:00
5d73fe455a refactor(icons): general app icon changed; jupyter app icon changed to material icon 2024-08-29 13:04:04 +02:00
7dadab1f14 refactor: add option to select scan and hide arg bundle buttons 2024-08-29 12:57:40 +02:00
semantic-release
664bbce01d 0.99.10
Automatically generated by python-semantic-release
2024-08-29 09:36:18 +00:00
097946fd68 refactor(stop_button): stop button changed to QWidget and adapted for toolbar 2024-08-29 11:26:23 +02:00
4a890281f7 fix(stop_button): queue logic scan changed to halt instead of abort and reset 2024-08-29 10:56:16 +02:00
cdd175207e refactor: added hide option for device selection button 2024-08-28 22:33:47 +02:00
semantic-release
3210a42e42 0.99.9
Automatically generated by python-semantic-release
2024-08-28 20:29:26 +00:00
719254cf0a fix: fixed build process and excluded docs and tests from tarballs and wheels 2024-08-28 22:20:34 +02:00
semantic-release
02193967de 0.99.8
Automatically generated by python-semantic-release
2024-08-28 19:33:38 +00:00
5f37e862c9 fix(website): fixed designer integration for website widget 2024-08-28 21:24:15 +02:00
9925bbdb48 refactor(website): changed inheritance of website widget to simple qwidget; closes #325 2024-08-28 21:24:15 +02:00
semantic-release
1f7ca4813c 0.99.7
Automatically generated by python-semantic-release
2024-08-28 15:04:54 +00:00
ffc871ebbd fix(toolbar): material icons can accept color as kwarg 2024-08-28 16:16:23 +02:00
semantic-release
7b9a36403d 0.99.6
Automatically generated by python-semantic-release
2024-08-28 13:42:33 +00:00
09c6c93c39 fix(toolbar): use of native qt separators 2024-08-28 15:33:39 +02:00
c31e9a3aff docs: various bugs fixed 2024-08-28 15:17:31 +02:00
semantic-release
960d84b7fe 0.99.5
Automatically generated by python-semantic-release
2024-08-28 13:09:31 +00:00
e6f204b6aa fix(dock_area): dark button added 2024-08-28 15:06:59 +02:00
02239de0a3 docs(index): index page is centered 2024-08-28 15:06:08 +02:00
semantic-release
0aad9a0988 0.99.4
Automatically generated by python-semantic-release
2024-08-28 13:05:07 +00:00
c5501860e8 fix(theme): apply theme to all pyqtgraph widgets on manual updates 2024-08-28 14:34:50 +02:00
4e5520aee2 docs(buttons): added missing buttons docs 2024-08-27 20:58:05 +02:00
4591ba8f73 refactor(buttons): changed grid and thumbnail fig in gallery 2024-08-27 20:58:05 +02:00
f335763280 refactor(icons): removed toolbar icons from assets 2024-08-27 18:38:08 +02:00
e890091d86 refactor(icons): moved widget icons to class attribute ICON_NAME 2024-08-27 18:38:08 +02:00
ac2cb5197d docs(developer): tutorial for BECWidget base class 2024-08-27 18:18:10 +02:00
semantic-release
65345187b3 0.99.3
Automatically generated by python-semantic-release
2024-08-27 13:49:04 +00:00
d48243483e build: updated min version of bec qthemes 2024-08-27 13:09:21 +02:00
1ca9499edd fix(cmaps): unified all defaults to magma cmap 2024-08-27 13:09:21 +02:00
060935ffc5 fix(color maps): color maps should take the background color into account; fixed min colors to 10 2024-08-27 12:36:02 +02:00
semantic-release
50dbef52c0 0.99.2
Automatically generated by python-semantic-release
2024-08-27 09:03:23 +00:00
bb385f07ca ci: additional tests are not allowed to fail 2024-08-27 10:54:46 +02:00
cf28730515 fix(widgets): fixed default theme for widgets
If not theme is set, the init of the BECWidget base class sets the default theme to "dark"
2024-08-27 10:54:46 +02:00
semantic-release
13ae383455 0.99.1
Automatically generated by python-semantic-release
2024-08-27 07:28:47 +00:00
2265458dcc fix(crosshair): emit all crosshair events, not just line coordinates 2024-08-26 14:10:46 +02:00
semantic-release
0a59f08fcc 0.99.0
Automatically generated by python-semantic-release
2024-08-25 11:49:52 +00:00
c70724a456 refactor(darkmodebutton): renamed set_dark_mode_enabled to toggle_dark_mode 2024-08-25 13:45:56 +02:00
406c263746 docs(darkmodebutton): added dark mode button docs 2024-08-25 13:45:56 +02:00
df35aabff3 test(dark_mode_button): added tests for dark mode button 2024-08-25 13:45:56 +02:00
cc8c166b5c feat(darkmodebutton): added button to toggle between dark and light mode 2024-08-25 13:45:56 +02:00
c4f3308dc0 fix(toggle): emit state change 2024-08-25 13:45:56 +02:00
semantic-release
8f3824c0e7 0.98.0
Automatically generated by python-semantic-release
2024-08-25 11:45:36 +00:00
afdf4e8782 fix(toolbar): removed hardcoded color values 2024-08-23 23:00:49 +02:00
2a82032644 fix: transitioning to material icons 2024-08-23 22:40:21 +02:00
88a2f66758 fix(dock_area): transitioned to MaterialIconAction 2024-08-23 22:05:56 +02:00
3f3b207295 fix: fix color palette if qtheme was not called 2024-08-23 20:14:53 +02:00
44cfda1c07 refactor(waveform): use set theme for demo 2024-08-23 20:04:44 +02:00
e42b84c636 fix(figure): removed theme from figure init 2024-08-23 20:04:44 +02:00
77c5aa741c fix: use globally set theme instead of the internal bec widgets theme 2024-08-23 20:04:44 +02:00
2b4449afeb feat(themes): added set_theme method 2024-08-23 20:04:44 +02:00
36ad464159 fix(waveform): fixed icon appearance 2024-08-23 20:04:44 +02:00
semantic-release
e8ae6f2e43 0.97.0
Automatically generated by python-semantic-release
2024-08-23 13:06:31 +00:00
3ecbd60627 fix(toolbar icon): fixed material icon toolbar for theme changes 2024-08-23 14:14:40 +02:00
82a55ddf3e feat(designer): added designer icon factory 2024-08-23 14:12:33 +02:00
semantic-release
7d190719b1 0.96.3
Automatically generated by python-semantic-release
2024-08-23 07:47:59 +00:00
8c2e7c8259 fix: minor fixes for type annotations 2024-08-22 20:44:28 +02:00
dd7c71bb1e docs(dispatcher): docs added 2024-08-22 14:52:52 +02:00
semantic-release
7b5b7a8cbb 0.96.2
Automatically generated by python-semantic-release
2024-08-22 09:49:04 +00:00
af28574bd5 fix(waveform): validation of custom curves removed 2024-08-22 11:35:27 +02:00
617db36ed4 fix(waveform): skip validation for curves that are not BECCurve instances 2024-08-22 10:55:49 +02:00
semantic-release
ebc2e44c7c 0.96.1
Automatically generated by python-semantic-release
2024-08-22 08:41:33 +00:00
44738057a3 fix(crosshair): update markers if necessary 2024-08-22 10:32:35 +02:00
f98a9f9771 fix(waveform_widget): fixed icon appearance 2024-08-22 10:32:35 +02:00
2fe72c9ccb fix: bubble-up signals 2024-08-22 10:32:35 +02:00
f0203d9bf6 ci: fail pytest after 2 failed tests 2024-08-22 10:32:35 +02:00
37835cbf76 fix(crosshair): fixed crosshair for image and waveforms 2024-08-22 10:32:35 +02:00
semantic-release
e005be33d1 0.96.0
Automatically generated by python-semantic-release
2024-08-22 07:50:58 +00:00
9d7718c3d9 docs(scan_control): added designer options 2024-08-22 09:42:00 +02:00
9d8fb0b761 feat(scan_control): added the ability to configure the scan control widget from designer 2024-08-22 09:42:00 +02:00
semantic-release
9df1e0899b 0.95.1
Automatically generated by python-semantic-release
2024-08-22 07:36:54 +00:00
640464a654 fix(docs): changed link to scan gui config in main docs 2024-08-21 21:46:44 +02:00
84abe46050 refactor: removed designer pngs 2024-08-21 21:28:32 +02:00
1d2afaa09e refactor: moved to dynamically loaded material design icons 2024-08-21 21:28:32 +02:00
2bf5c7096e docs: links section added 2024-08-21 21:07:50 +02:00
semantic-release
41dc6e6cfd 0.95.0
Automatically generated by python-semantic-release
2024-08-21 13:50:10 +00:00
650039303a fix(device_browser): fixed plugin assignment for designer 2024-08-21 15:41:23 +02:00
196504b533 feat(cli): added device_browser to cli 2024-08-21 15:29:22 +02:00
2c31cc90ae docs(device_browser): added user docs 2024-08-21 15:29:02 +02:00
e870e5ba08 test: added test for device browser 2024-08-21 14:38:34 +02:00
73f5a2f085 feat(widgets): added device_browser widget 2024-08-21 14:38:34 +02:00
4790afde3d refactor(docs): review response 2024-08-21 13:18:48 +02:00
7357f3d2a1 docs(user): widget gallery with documentation added 2024-08-21 13:18:48 +02:00
e9ecd268c6 docs: added sphinx-inline-tabs as sphinx dependency 2024-08-21 13:18:48 +02:00
91ba30e8d0 docs(cards): changed index cards to custom css class instead of overwriting the default sd-card theme 2024-08-21 13:18:48 +02:00
semantic-release
d36d801ef1 0.94.7
Automatically generated by python-semantic-release
2024-08-20 13:17:23 +00:00
939f834a26 fix: formatting of stdout, stderr captured text for logger 2024-08-14 18:01:51 +02:00
semantic-release
bee51bd86e 0.94.6
Automatically generated by python-semantic-release
2024-08-14 15:05:11 +00:00
bc2abe945f fix(server): emit heartbeat with state 2024-08-14 16:55:04 +02:00
semantic-release
49a5a23d41 0.94.5
Automatically generated by python-semantic-release
2024-08-14 12:01:45 +00:00
4f96d0e4a1 build: increased min version of bec to 2.21.4
Since we now rely on reusing the BECClient singleton, we need the fix introduced with 2.21.4 in BEC.
2024-08-14 12:32:34 +02:00
ea9240d2f7 fix(rpc): use client singleton instead of dispatcher 2024-08-14 12:32:34 +02:00
4d02b42f11 fix: removed qcoreapplication for polling events 2024-08-14 12:32:34 +02:00
semantic-release
9509be14be 0.94.4
Automatically generated by python-semantic-release
2024-08-14 08:46:53 +00:00
198c1d1064 fix: do not shutdown client in "close"
Terminating client connections has to be done at the application level
2024-08-13 12:23:51 +02:00
2af5c94913 docs: review developer section; add introduction 2024-08-13 11:06:24 +02:00
semantic-release
a4a0bac3c1 0.94.3
Automatically generated by python-semantic-release
2024-08-13 09:03:11 +00:00
f285b35b49 test(waveform_widget): added tests for axis setting and curve dialog 2024-08-13 10:53:44 +02:00
7aeb2b5c26 fix(curve_dialog): async curves are shown in curve dialog after addition. 2024-08-13 10:53:44 +02:00
d56ea95ef9 fix(waveform): async device entry is correctly passed, updated and with new scan the previous data are cleared 2024-08-13 10:53:44 +02:00
semantic-release
5733fea98c 0.94.2
Automatically generated by python-semantic-release
2024-08-13 08:53:10 +00:00
98b79aac7b fix(image): image is single image mode do not raise popup error when connected twice with the same monitor 2024-08-12 11:24:08 +02:00
semantic-release
4212fe0e32 0.94.1
Automatically generated by python-semantic-release
2024-08-12 08:53:48 +00:00
93d397759c fix: issue #292, wrong key was used to clean _slots internal dictionary 2024-08-12 10:32:35 +02:00
semantic-release
8c5b901a37 0.94.0
Automatically generated by python-semantic-release
2024-08-08 14:59:14 +00:00
0273bf4856 refactor: adjust dimensions 2024-08-08 15:11:43 +02:00
c80a7cd108 feat: add PositionerControlLine 2024-08-08 14:58:16 +02:00
semantic-release
a50d9c7b3f 0.93.5
Automatically generated by python-semantic-release
2024-08-08 11:48:07 +00:00
281633deff fix(positioner_box): icons fixed 2024-08-08 13:34:07 +02:00
0d190c5c59 refactor: add button for positioner selection 2024-08-08 13:34:07 +02:00
6269009e54 test(dap): wait for fit 2024-08-07 20:19:37 +02:00
6d2442d23c test(auto-update): wait for rendering 2024-08-07 19:58:26 +02:00
semantic-release
110b27351b 0.93.4
Automatically generated by python-semantic-release
2024-08-07 16:21:43 +00:00
37aa371e7c fix: rename DeviceBox to PositionerBox, fix test for validation 2024-08-07 17:56:48 +02:00
eb54e9f788 fix: add validation for bec_lib.device.Positioner; closes #268 2024-08-07 15:45:39 +02:00
semantic-release
482efeb340 0.93.3
Automatically generated by python-semantic-release
2024-08-07 13:14:54 +00:00
99ee545e41 fix(dock): properly shut down docks and temp areas 2024-08-07 13:58:43 +02:00
cf94599c25 test: removed quit from teardown 2024-08-07 12:25:54 +02:00
b50b3a27e6 fix(settings): shut down settings dialog 2024-08-07 12:25:54 +02:00
bf6294ecbf test: removed explicit call to close the widget 2024-08-07 12:25:54 +02:00
a3d4f5ac4b fix(website): fixed teardown of website widgets 2024-08-07 11:15:14 +02:00
bc264975b1 fix(dock): properly shut down docks and dock areas 2024-08-07 11:00:25 +02:00
ad07bbf85e fix(figure): cleanup pyqtgraph 2024-08-07 10:12:49 +02:00
9856857f4c test: use factory instead of fixture to properly cleanup widgets on teardown 2024-08-07 10:12:49 +02:00
f9e5897900 test: ensure all toplevelwidgets are closed 2024-08-07 10:12:49 +02:00
semantic-release
39fb22b716 0.93.2
Automatically generated by python-semantic-release
2024-08-07 07:57:18 +00:00
a372925fff fix(scan_group_box): Scan Spinboxes limits increased to max allowed values; setting dialog for step size and decimal precision for ScanDoubleSpinBox on right click 2024-08-07 09:47:06 +02:00
semantic-release
ec54440569 0.93.1
Automatically generated by python-semantic-release
2024-08-06 21:56:54 +00:00
af86860bf3 fix(dock): docks have more recognizable red icon for closing docks 2024-08-06 19:23:31 +02:00
302ae90139 docs: added video tutorial section with BSEG YT video 2024-08-06 17:42:15 +02:00
semantic-release
1405068925 0.93.0
Automatically generated by python-semantic-release
2024-08-05 14:11:40 +00:00
5aad401ef8 feat(themes): moved themes to bec_qthemes
This reverts commit fd6ae91993
2024-08-05 14:24:05 +02:00
semantic-release
885dcfda89 0.92.5
Automatically generated by python-semantic-release
2024-08-05 12:20:01 +00:00
30fef929cf fix(spinner): stop timer on close event 2024-08-05 13:54:20 +02:00
1f30dd73a9 fix(status_box): fix cleanup of status box 2024-08-05 13:54:20 +02:00
73cd11e472 test: register all widgets with qtbot and close them 2024-08-05 13:54:20 +02:00
7616ca0e14 refactor(queue): refactored bec queue to inherit only from qwidget 2024-08-05 13:54:20 +02:00
semantic-release
ca29a69779 0.92.4
Automatically generated by python-semantic-release
2024-07-31 07:24:44 +00:00
dcc5fd71ee fix: fix missmatch of signal/slot in image and motormap 2024-07-29 16:05:21 +02:00
583 changed files with 36470 additions and 4869 deletions

View File

@@ -5,9 +5,16 @@ image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
BEC_CORE_BRANCH:
description: bec branch
value: main
OPHYD_DEVICES_BRANCH:
description: ophyd_devices branch
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
rules:
@@ -27,8 +34,9 @@ include:
inputs:
stage: test
path: "."
pytest_args: "-v --random-order tests/"
exclude_packages: ""
pytest_args: "-v,--random-order,tests/unit_tests"
ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
# different stages in the pipeline
stages:
@@ -53,6 +61,7 @@ stages:
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e ./bec/pytest_bec_e2e
.install-os-packages: &install-os-packages
- apt-get update
@@ -69,9 +78,9 @@ formatter:
stage: Formatter
needs: []
script:
- pip install black isort
- isort --check --diff ./
- black --check --diff --color ./
- pip install bec_lib[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
@@ -139,8 +148,8 @@ tests:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- pip install -e .[dev,pyside6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
@@ -163,7 +172,6 @@ test-matrix:
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt6"
stage: AdditionalTests
needs: []
@@ -177,8 +185,7 @@ test-matrix:
- *install-os-packages
- *install-repos
- pip install -e .[dev,$QT_PCKG]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
end-2-end-conda:
stage: End2End
@@ -203,7 +210,7 @@ end-2-end-conda:
- cd ../
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyqt6]
- pip install -e .[dev,pyside6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,17 @@
# 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).
## Installation
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```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:
@@ -14,22 +19,12 @@ For development purposes, you can clone the repository and install the package l
```bash
git clone https://gitlab.psi.ch/bec/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
Python Qt distributions manually.
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
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 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:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
>
> ```
> <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
> ```
>
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
>
> Must be one of the following:
>
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
@@ -71,4 +66,5 @@ All commits should use the Angular commit scheme:
## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)

View File

@@ -0,0 +1,198 @@
""" This module contains the GUI for the 1D alignment application.
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
"""
import os
from typing import Optional
from bec_lib.device import Signal as BECSignal
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
PositionerGroup,
)
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
class Alignment1D:
"""Alignment GUI to perform 1D scans"""
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
"""Initialization
Args:
config: Configuration of the application.
client: BEC client object.
gui_id: GUI ID.
"""
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
QApplication.instance().aboutToQuit.connect(self.close)
self.dev = self.client.device_manager.devices
self._accent_colors = get_accent_colors()
self.ui_file = "alignment_1d.ui"
self.ui = None
self.progress_bar = None
self.waveform = None
self.init_ui()
def init_ui(self):
"""Initialise the UI from QT Designer file"""
current_path = os.path.dirname(__file__)
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
# Customize the plotting widget
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
self._customise_bec_waveform_widget()
# Setup comboboxes for motor and signal selection
# FIXME after changing the filtering in the combobox
self._setup_signal_combobox()
# Setup motor indicator
self._setup_motor_indicator()
# Setup progress bar
self._setup_progress_bar()
# Add actions buttons
self._customise_buttons()
# Hook scaninfo updates
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
def show(self):
return self.ui.show()
##############################
############ SLOTS ###########
##############################
@Slot(dict, dict)
def scan_status_callback(self, content: dict, _) -> None:
"""This slot allows to enable/disable the UI critical components when a scan is running"""
if content["status"] in ["open"]:
self.enable_ui(False)
elif content["status"] in ["aborted", "halted", "closed"]:
self.enable_ui(True)
@Slot(tuple)
def move_to_center(self, move_request: tuple) -> None:
"""Move the selected motor to the center"""
motor = self.ui.device_combobox.currentText()
if move_request[0] in ["center", "center1", "center2"]:
pos = move_request[1]
self.dev.get(motor).move(float(pos), relative=False)
@Slot()
def reset_progress_bar(self) -> None:
"""Reset the progress bar"""
self.progress_bar.set_value(0)
self.progress_bar.set_minimum(0)
@Slot(dict, dict)
def update_progress_bar(self, content: dict, _) -> None:
"""Hook to update the progress bar
Args:
content: Content of the scan progress message.
metadata: Metadata of the message.
"""
if content["max_value"] == 0:
self.progress_bar.set_value(0)
return
self.progress_bar.set_maximum(content["max_value"])
self.progress_bar.set_value(content["value"])
@Slot()
def clear_queue(self) -> None:
"""Clear the scan queue"""
self.queue.request_queue_reset()
##############################
######## END OF SLOTS ########
##############################
def enable_ui(self, enable: bool) -> None:
"""Enable or disable the UI components"""
# Enable/disable motor and signal selection
self.ui.device_combobox_2.setEnabled(enable)
# Enable/disable DAP selection
self.ui.dap_combo_box.setEnabled(enable)
# Enable/disable Scan Button
# self.ui.scan_button.setEnabled(enable)
# Disable move to buttons in LMFitDialog
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
def _customise_buttons(self) -> None:
"""Add action buttons for the Action Control.
In addition, we are adding a callback to also clear the queue to the stop button
to ensure that upon clicking the button, no scans from another client may be queued
which would be confusing without the queue widget.
"""
fit_dialog = self.ui.findChild(LMFitDialog)
fit_dialog.active_action_list = ["center", "center1", "center2"]
fit_dialog.move_action.connect(self.move_to_center)
stop_button = self.ui.findChild(StopButton)
stop_button.button.setText("Stop and Clear Queue")
stop_button.button.clicked.connect(self.clear_queue)
def _customise_bec_waveform_widget(self) -> None:
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
self.waveform.toolbar.clear()
def _setup_motor_indicator(self) -> None:
"""Setup the arrow item"""
self.waveform.waveform.tick_item.add_to_plot()
positioner_box = self.ui.findChild(PositionerGroup)
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
self.waveform.waveform.tick_item.set_position(0)
def _setup_signal_combobox(self) -> None:
"""Setup signal selection"""
# FIXME after changing the filtering in the combobox
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
self.ui.device_combobox_2.setCurrentText(signals[0])
self.ui.device_combobox_2.set_device_filter("Signal")
def _setup_progress_bar(self) -> None:
"""Setup progress bar"""
# FIXME once the BECScanProgressBar is implemented
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
self.progress_bar.set_value(0)
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
def close(self):
logger.info("Disconnecting", repr(self.bec_dispatcher))
self.bec_dispatcher.disconnect_all()
logger.info("Shutting down BEC Client", repr(self.client))
self.client.shutdown()
def main():
import sys
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
window = Alignment1D()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,615 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>mainWindow</class>
<widget class="QMainWindow" name="mainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1611</width>
<height>1019</height>
</rect>
</property>
<property name="windowTitle">
<string>Alignment tool</string>
</property>
<widget class="QWidget" name="widget">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QWidget" name="widget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="DarkModeButton" name="dark_mode_button"/>
</item>
<item>
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BECStatusBox" name="bec_status_box">
<property name="compact_view" stdset="0">
<bool>true</bool>
</property>
<property name="label" stdset="0">
<string>BEC Servers</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BECQueue" name="bec_queue">
<property name="compact_view" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QRadioButton" name="radioButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>SLS Light On</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QRadioButton" name="radioButton_3">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>BEAMLINE Checks</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="StopButton" name="stop_button">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>40</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>40</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="BECProgressBar" name="bec_progress_bar">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="ControlTab">
<attribute name="title">
<string>Alignment Control</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QWidget" name="widget_4" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="ScanControl" name="scan_control">
<property name="current_scan" stdset="0">
<string>line_scan</string>
</property>
<property name="hide_arg_box" stdset="0">
<bool>false</bool>
</property>
<property name="hide_scan_selection_combobox" stdset="0">
<bool>true</bool>
</property>
<property name="hide_add_remove_buttons" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="PositionerGroup" name="positioner_group"/>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="widget_3" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>4</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="widget_2" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="label_2">
<property name="font">
<font/>
</property>
<property name="text">
<string>Monitor</string>
</property>
</widget>
</item>
<item>
<widget class="DeviceComboBox" name="device_combobox_2"/>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="font">
<font/>
</property>
<property name="text">
<string>LMFit Model</string>
</property>
</widget>
</item>
<item>
<widget class="DapComboBox" name="dap_combo_box"/>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Enable ROI</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="toggle_switch">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>3</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Activate linear region select for LMFit</string>
</property>
<property name="layoutDirection">
<enum>Qt::LayoutDirection::LeftToRight</enum>
</property>
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_8">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="BECWaveformWidget" name="bec_waveform_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>600</width>
<height>450</height>
</size>
</property>
<property name="clear_curves_on_plot_update" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="LMFitDialog" name="lm_fit_dialog">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>190</height>
</size>
</property>
<property name="always_show_latest" stdset="0">
<bool>true</bool>
</property>
<property name="hide_curve_selection" stdset="0">
<bool>true</bool>
</property>
<property name="hide_summary" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Logbook</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="WebsiteWidget" name="website_widget">
<property name="url" stdset="0">
<string>https://scilog.psi.ch/login</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>DapComboBox</class>
<extends>QWidget</extends>
<header>dap_combo_box</header>
</customwidget>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWidget</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
<customwidget>
<class>DarkModeButton</class>
<extends>QWidget</extends>
<header>dark_mode_button</header>
</customwidget>
<customwidget>
<class>PositionerGroup</class>
<extends>QWidget</extends>
<header>positioner_group</header>
</customwidget>
<customwidget>
<class>BECWaveformWidget</class>
<extends>QWidget</extends>
<header>bec_waveform_widget</header>
</customwidget>
<customwidget>
<class>DeviceComboBox</class>
<extends>QComboBox</extends>
<header>device_combobox</header>
</customwidget>
<customwidget>
<class>LMFitDialog</class>
<extends>QWidget</extends>
<header>lm_fit_dialog</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>toggle_switch</sender>
<signal>enabled(bool)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>toogle_roi_select(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>1042</x>
<y>212</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>322</y>
</hint>
</hints>
</connection>
<connection>
<sender>bec_waveform_widget</sender>
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
<receiver>lm_fit_dialog</receiver>
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
<hints>
<hint type="sourcelabel">
<x>1099</x>
<y>258</y>
</hint>
<hint type="destinationlabel">
<x>1157</x>
<y>929</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combobox_2</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>plot(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>427</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combobox_2</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_y_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>909</x>
<y>215</y>
</hint>
</hints>
</connection>
<connection>
<sender>dap_combo_box</sender>
<signal>new_dap_config(QString,QString,QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>add_dap(QString,QString,QString)</slot>
<hints>
<hint type="sourcelabel">
<x>909</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>447</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>positioner_group</receiver>
<slot>set_positioners(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>230</x>
<y>306</y>
</hint>
<hint type="destinationlabel">
<x>187</x>
<y>926</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>set_x(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>187</x>
<y>356</y>
</hint>
<hint type="destinationlabel">
<x>972</x>
<y>509</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_x_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>187</x>
<y>356</y>
</hint>
<hint type="destinationlabel">
<x>794</x>
<y>202</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,84 @@
"""
Launcher for BEC GUI Applications
Application must be located in bec_widgets/applications ;
in order for the launcher to find the application, it has to be put in
a subdirectory with the same name as the main Python module:
/bec_widgets/applications
├── alignment
│ └── alignment_1d
│ └── alignment_1d.py
├── other_app
└── other_app.py
The tree above would contain 2 applications, alignment_1d and other_app.
The Python module for the application must have `if __name__ == "__main__":`
in order for the launcher to execute it (it is run with `python -m`).
"""
import argparse
import os
import sys
MODULE_PATH = os.path.dirname(__file__)
def find_apps(base_dir: str) -> list[str]:
matching_modules = []
for root, dirs, files in os.walk(base_dir):
parent_dir = os.path.basename(root)
for file in files:
if file.endswith(".py") and file != "__init__.py":
file_name_without_ext = os.path.splitext(file)[0]
if file_name_without_ext == parent_dir:
rel_path = os.path.relpath(root, base_dir)
module_path = rel_path.replace(os.sep, ".")
module_name = f"{module_path}.{file_name_without_ext}"
matching_modules.append((file_name_without_ext, module_name))
return matching_modules
def main():
parser = argparse.ArgumentParser(description="BEC application launcher")
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
# Add a positional argument for the module, which acts as a fallback if -m is not provided
parser.add_argument(
"positional_module",
nargs="?", # This makes the positional argument optional
help="Positional argument that is treated as module if -m is not specified.",
)
args = parser.parse_args()
# If the -m/--module is not provided, fallback to the positional argument
module = args.module if args.module else args.positional_module
if module:
for app_name, app_module in find_apps(MODULE_PATH):
if module in (app_name, app_module):
print("Starting:", app_name)
python_executable = sys.executable
# Replace the current process with the new Python module
os.execvp(
python_executable,
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
)
print(f"Error: cannot find application {module}")
# display list of apps
print("Available applications:")
for app, _ in find_apps(MODULE_PATH):
print(f" - {app}")
if __name__ == "__main__": # pragma: no cover
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
<path d="M479.85-265.87q19.8 0 32.69-12.46 12.9-12.46 12.9-32.26 0-19.8-12.75-32.98-12.74-13.17-32.54-13.17-19.8 0-32.69 13.16-12.9 13.15-12.9 32.95 0 19.8 12.75 32.28 12.74 12.48 32.54 12.48Zm-36.46-166.56h79.22v-262.61h-79.22v262.61Zm36.95 366.56q-86.2 0-161.5-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.73-32.39-75.3-32.39-161.59t32.39-161.67q32.4-75.37 88.75-131.34t131.69-88.62q75.34-32.65 161.67-32.65 86.34 0 161.78 32.61 75.45 32.6 131.37 88.5 55.93 55.89 88.55 131.45 32.63 75.56 32.63 161.87 0 86.29-32.65 161.58t-88.62 131.48q-55.97 56.18-131.42 88.76-75.46 32.58-161.67 32.58Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 718 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
<path d="m759.04-283.09-63.13-62q49.31-9.43 84.44-46.02 35.13-36.59 35.13-86.14 0-55.49-39.42-95.08-39.43-39.58-94.93-39.58h-153.3v-79.79h152.74q89.28 0 151.7 62.71Q894.7-566.28 894.7-477q0 63.7-38.26 115.96-38.27 52.26-97.4 77.95ZM596.83-443.61l-65.66-66.78h110.05v66.78h-44.39ZM804.96-56 58.48-802.48 106-850l746.48 746.48L804.96-56ZM443.22-265.87H279.43q-89.28 0-151.7-62.42Q65.3-390.72 65.3-480q0-72.57 43.09-129.54 43.09-56.98 112.09-76.07l70.13 70.7h-11.18q-55.73 0-95.32 39.3-39.59 39.31-39.59 95.61t39.66 95.61q39.66 39.3 95.5 39.3h163.54v79.22ZM319.35-446.61v-66.78h77.3l66.78 66.78H319.35Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 721 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFF55">
<path d="M478.3-145.87q-138.65 0-236.39-97.74-97.74-97.74-97.74-236.25t97.74-236.68q97.74-98.16 236.39-98.16 88.4 0 155.45 35.76 67.04 35.76 115.86 98.9V-814.7h66.78v274.92H540.91V-606h165.74q-38.56-57.74-95.3-93.33-56.74-35.58-133.05-35.58-106.88 0-180.89 73.98-74.02 73.99-74.02 180.83 0 106.84 74.02 180.93 74.02 74.08 180.91 74.08 80.16 0 147.74-46.08 67.59-46.09 95.16-121.83H803q-29.56 110.65-119.67 178.89-90.1 68.24-205.03 68.24Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 559 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#75FB4C">
<path d="m419.87-289.52 289.22-289.22-57.31-56.87L419.87-403.7 304.96-518.61l-56.31 56.87 171.22 172.22Zm60.21 223.65q-85.47 0-161.01-32.39-75.53-32.4-131.97-88.84-56.44-56.44-88.84-131.89-32.39-75.46-32.39-160.93 0-86.47 32.39-162.01 32.4-75.53 88.75-131.5t131.85-88.62q75.5-32.65 161.01-32.65 86.52 0 162.12 32.61 75.61 32.6 131.53 88.5 55.93 55.89 88.55 131.45Q894.7-566.58 894.7-480q0 85.55-32.65 161.07-32.65 75.53-88.62 131.9-55.97 56.37-131.42 88.77-75.46 32.39-161.93 32.39Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 604 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#F19E39">
<path d="M27.56-112.65 480-894.7l452.44 782.05H27.56Zm456.62-125.48q13.15 0 22.61-9.64 9.47-9.65 9.47-22.8t-9.64-22.33q-9.65-9.19-22.8-9.19t-22.61 9.36q-9.47 9.36-9.47 22.51 0 13.15 9.64 22.62 9.65 9.47 22.8 9.47ZM454-348h60v-219.48h-60V-348Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 364 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M440.39-440.39H185.87v-79.22h254.52V-774.7h79.22v255.09H774.7v79.22H519.61v254.52h-79.22v-254.52Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="m145.26-88.13-57.13-57.13 137.39-137.39H105.87v-79.22h256v256h-79.22v-119.65L145.26-88.13Zm669.48 0L677.91-225.52v119.65h-79.78v-256H854.7v79.22H734.48l137.39 137.39-57.13 57.13Zm-708.87-510v-79.78h119.65L88.13-814.74l57.13-57.13 137.39 137.39V-854.7h79.22v256.57h-256Zm492.26 0V-854.7h79.78v120.22l137.83-138.39 57.13 57.13-138.39 137.83H854.7v79.78H598.13Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 489 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M114.02-114.02v-308.13h68.13v192.02l547.72-547.72H537.85v-68.37h308.37v308.37h-68.37v-192.02L230.13-182.15h192.02v68.13H114.02Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 258 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="m311.5-154.02-47.74-47.74 116.94-116.7H74.02v-68.37H380.7L263.76-503.76l47.74-47.74 198.98 198.74L311.5-154.02Zm337-254.72L449.76-607.48 648.5-806.22l47.74 47.74-116.7 116.94h306.68v68.37H579.54l116.7 116.69-47.74 47.74Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 351 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-2zm-3-4h8v2H8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M403.39-158.74 82.13-480l321.26-321.26v642.52Zm153.22 0v-642.52L878.44-480 556.61-158.74Zm81.57-195.74L763.13-480 638.18-605.52v251.04Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 266 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M480-54 303.43-230.56 361-288.13l79.39 78.83v-231.09H209.3L283.13-366l-57.57 57.57L54-480l172.56-172.57L284.13-595l-74.83 75.39h231.09v-231.65L366-676.87l-57.57-57.57L480-906l171.57 171.56L594-676.87l-74.39-74.39v231.65h231.65L676.87-594l57.57-57.57L906-480 734.44-308.43 676.87-366l74.39-74.39H519.61v231.09L599-288.13l57.57 57.57L480-54Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 470 B

View File

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
fill="#FFFFFF">
<g>
<rect fill="none" height="24" width="24"/>
</g>
<g>
<path d="M18,15v3H6v-3H4v3c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-3H18z M7,9l1.41,1.41L11,7.83V16h2V7.83l2.59,2.58L17,9l-5-5L7,9z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 371 B

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 96" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect id="Artboard1" x="0" y="0" width="100" height="96.486" style="fill:none;"/>
<g id="Artboard11" serif:id="Artboard1">
<path d="M11.379,24.832C11.379,24.261 11.843,23.798 12.414,23.798C18.11,23.798 20.117,19.072 22.06,14.503C23.704,10.634 25.403,6.634 29.483,6.634C33.902,6.634 35.376,12.91 36.934,19.555C38.473,26.113 40.065,32.893 44.138,32.893C48.25,32.893 50.78,28.962 53.457,24.8C56.279,20.412 59.198,15.876 64.31,15.876C69.322,15.876 72.165,20.305 74.915,24.588C77.707,28.935 80.343,33.04 84.999,33.04C85.571,33.04 86.034,33.503 86.034,34.075C86.034,34.647 85.571,35.11 84.999,35.11C79.212,35.11 76.004,30.115 73.175,25.708C70.612,21.716 68.192,17.946 64.31,17.946C60.328,17.946 57.835,21.819 55.197,25.92C52.338,30.366 49.381,34.963 44.138,34.963C38.425,34.963 36.643,27.371 34.92,20.028C33.613,14.46 32.262,8.703 29.482,8.703C26.953,8.703 25.71,11.2 23.963,15.311C21.964,20.014 19.479,25.867 12.413,25.867C11.843,25.867 11.379,25.403 11.379,24.832M44.361,44.584C43.504,44.584 42.557,44.882 42.557,45.739L42.586,50.703L39.522,50.703L43.922,61.878L48.807,50.703L45.691,50.703L45.604,46.255C45.602,45.398 45.218,44.584 44.361,44.584ZM6.034,37.487L6.034,6.674L5,6.674L5,38.522L95,38.522L95,37.487L6.034,37.487M77.414,91.881L77.414,63.849C77.414,63.277 76.951,62.814 76.379,62.814C75.808,62.814 75.345,63.277 75.345,63.849L75.345,91.881C75.345,92.045 75.391,92.194 75.458,92.332L61.955,92.332C62.022,92.194 62.068,92.045 62.068,91.881L62.068,82.718C62.068,82.146 61.605,81.683 61.034,81.683C60.462,81.683 59.999,82.146 59.999,82.718L59.999,91.881C59.999,92.045 60.045,92.194 60.112,92.332L45.059,92.332C45.126,92.194 45.172,92.045 45.172,91.881L45.172,75.943C45.172,75.372 44.709,74.909 44.138,74.909C43.567,74.909 43.104,75.372 43.104,75.943L43.104,91.881C43.104,92.045 43.15,92.194 43.217,92.332L23.852,92.332C23.92,92.194 23.966,92.045 23.966,91.881L23.966,63.849C23.966,63.277 23.502,62.814 22.931,62.814C22.36,62.814 21.897,63.277 21.897,63.849L21.897,91.881C21.897,92.045 21.943,92.194 22.011,92.332L6.034,92.332L6.034,62.881L5,62.881L5,93.366L95,93.366L95,92.332L77.301,92.332C77.368,92.194 77.414,92.045 77.414,91.881"
style="fill:white;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="m78.89-112.59 263.02-367.17h202l303.5-354.22v721.39H78.89Zm62.24-262.74-54.7-39.78 166.59-233.02h201L640.46-864.8l51.45 44.26-205.82 240.78H287.33l-146.2 204.43Zm70.54 194.37h567.37v-468.78L574.98-411.63H376.22L211.67-180.96Zm567.37 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 366 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h589.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v589.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09Zm43.56-166.04h503.7L578-481.48l-132 171-93-127-124.35 165.57Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 413 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M283.85-289.91h67.41l42.32-112.66h172.59l43.33 112.66h68.85l-164-428h-67.5l-163 428Zm127.74-165.72 67.34-182.87H481l68.41 182.87H411.59Zm68.53 381.61q-86.32 0-160.51-31t-128.89-85.7q-54.7-54.7-85.7-128.89-31-74.19-31-160.51 0-85.31 30.94-159.4t85.7-128.9q54.76-54.8 128.95-86.3t160.51-31.5q85.31 0 159.42 31.47 74.1 31.47 128.91 86.27 54.82 54.8 86.29 128.88 31.48 74.08 31.48 159.6 0 86.2-31.5 160.39-31.5 74.19-86.3 128.95-54.81 54.76-128.9 85.7-74.09 30.94-159.4 30.94ZM480-480Zm-.04 337.85q144.08 0 240.99-96.74 96.9-96.74 96.9-241.07 0-144.32-96.86-241.11-96.86-96.78-240.95-96.78-144.08 0-240.99 96.74-96.9 96.74-96.9 241.07 0 144.32 96.86 241.11 96.86 96.78 240.95 96.78Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 809 B

View File

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
fill="#FFFFFF">
<g>
<rect fill="none" height="24" width="24"/>
</g>
<g>
<path d="M18,15v3H6v-3H4v3c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-3H18z M17,11l-1.41-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5 L17,11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 377 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M110.39-110.39v-89.57l77.52-77.52v167.09h-77.52Zm165.57 0v-250.13l77.52-77.52v327.65h-77.52Zm165.56 0v-327.65l77.52 77.95v249.7h-77.52Zm165.57 0v-250.83l77.52-76.96v327.79h-77.52Zm165.56 0v-411.83l76.96-76.96v488.79h-76.96ZM110.39-335.65v-112.7L400-735.96l160 160 289.61-290.61v112.14L560-463.26l-160-160-289.61 287.61Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M725.93-155.93q0-118.18-45-222.09t-122-180.91q-77-77-180.91-122t-222.09-45v-68.14q132.68 0 248.61 50.23 115.92 50.23 202.5 136.75 86.57 86.53 136.8 202.53 50.23 116.01 50.23 248.63h-68.14Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 319 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M571.91-279.09h191v-194h-60v134h-131v60ZM198.09-486.91h60v-134h131v-60h-191v194Zm-53 341.04q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-79.22h669.82v-509.82H145.09v509.82Zm0 0v-509.82 509.82Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M443.78-28.43v-75Q305.65-118 211.54-212.11q-94.11-94.11-108.11-231.67h-75v-72.44h75Q118-654.35 212.11-748.46q94.11-94.11 231.67-108.11v-75h72.44v75q137.56 14 231.67 108.11Q842-654.35 856.57-516.22h75v72.44h-75q-14 137.56-108.11 231.67Q654.35-118 516.22-103.43v75h-72.44Zm36.12-152.66q123.4 0 211.21-87.7 87.8-87.71 87.8-211.11 0-123.4-87.7-211.21-87.71-87.8-211.11-87.8-123.4 0-211.21 87.7-87.8 87.71-87.8 211.11 0 123.4 87.7 211.21 87.71 87.8 211.11 87.8ZM480-330q-63 0-106.5-43.5T330-480q0-63 43.5-106.5T480-630q63 0 106.5 43.5T630-480q0 63-43.5 106.5T480-330Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M354.61-386.61h391l-127-171-103 135-68-87-93 123ZM274.7-195.48q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-549.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h549.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v549.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H274.7Zm0-79.22h549.82v-549.82H274.7v549.82ZM135.48-55.69q-32.74 0-56.26-23.53-23.53-23.52-23.53-56.26v-629.04h79.79v629.04h629.04v79.79H135.48ZM274.7-824.52v549.82-549.82Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 564 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M480-65.87q-87.51 0-162.97-31.89-75.47-31.89-131.43-87.84-55.95-55.96-87.84-131.43Q65.87-392.49 65.87-480q0-87.47 31.88-162.87 31.89-75.39 87.75-131.51 55.87-56.11 131.41-88.21Q392.44-894.7 480-894.7q15.96 0 27.78 12.16 11.83 12.16 11.83 28.07 0 15.9-11.83 27.73-11.82 11.83-27.78 11.83-139.31 0-237.11 97.8-97.8 97.8-97.8 237.1 0 139.31 97.8 237.12 97.8 97.8 237.1 97.8 139.31 0 237.12-97.8 97.8-97.8 97.8-237.11 0-15.96 11.83-27.78 11.83-11.83 27.73-11.83 15.91 0 28.07 11.83Q894.7-495.96 894.7-480q0 87.56-32.14 163.1-32.14 75.54-88.11 131.44-55.97 55.9-131.44 87.74Q567.55-65.87 480-65.87Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 724 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M854.7-105.87H105.87v-209.61H854.7v209.61Zm0-269.61H105.87v-209.04H854.7v209.04Zm0-269.04H105.87V-854.7H854.7v210.18Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 248 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M444.19-421.3q39.72 0 67.4-27.78 27.67-27.78 27.67-66.91 0-39.14-27.71-66.57Q483.83-610 444.4-610q-39.44 0-66.92 27.28Q350-555.44 350-516.3q0 39.13 27.36 67.06 27.35 27.94 66.83 27.94ZM634.52-274 532.78-375.74q-22.56 12.31-44.41 19.02-21.84 6.72-43.28 6.72-69.57 0-117.7-48.62-48.13-48.62-48.13-117.88 0-67.98 48.29-116.39t117.08-48.41q68.79 0 117.08 48.41Q610-584.48 610-515.75q0 21.72-6.93 44.13-6.94 22.4-20.37 44.97l102.73 102.74L634.52-274ZM185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87V-352h79.22v166.91H352v79.22H185.09Zm422.91 0v-79.22h166.91V-352h79.79v166.91q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H608ZM105.87-608v-166.91q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53H352v79.79H185.09V-608h-79.22Zm669.04 0v-166.91H608v-79.79h166.91q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26V-608h-79.79Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 946 B

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
fill="#8B1A10">
<rect fill="none" height="24" width="24"/>
<path d="M19,19H5V5h14V19z M3,3v18h18V3H3z M17,15.59L15.59,17L12,13.41L8.41,17L7,15.59L10.59,12L7,8.41L8.41,7L12,10.59L15.59,7 L17,8.41L13.41,12L17,15.59z"/>
</svg>

Before

Width:  |  Height:  |  Size: 357 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M473.54-344.48v-63.59h187.18v63.59H473.54Zm81.92 230.46v-56.15h-81.92v-63.59h81.92v-56.39h63.58v176.13h-63.58Zm103.58-56.15v-63.59h187.18v63.59H659.04Zm41.68-116.92v-177.13h63.58v56.15h81.92v63.59H764.3v57.39h-63.58ZM841.22-540h-68.46q-22.98-101.89-103.94-170.83-80.95-68.93-188.89-68.93-125.29 0-212.37 87.18T180.48-480q0 78.61 36.59 143.32 36.58 64.7 96.95 104.46v-108.26h66.46v226.46H154.02v-66.46h117.41q-71.56-48.72-114.48-127.36-42.93-78.64-42.93-172.16 0-76.22 28.86-142.78 28.86-66.57 78.29-116.04 49.42-49.47 116.01-78.43 66.58-28.97 142.82-28.97 136.52 0 237.87 87.8Q819.22-670.63 841.22-540Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M336.04-487.39 145.09-678.35v156.65H65.87v-293H358.3v79.79H201.22l191.39 190.95-56.57 56.57ZM145.09-145.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87V-451.7h79.22v226.61H493v79.22H145.09Zm669.82-278.3v-310.74H428.3v-79.79h386.61q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v310.74h-79.79Zm79.79 60v218.87H553v-218.87h341.7Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 455 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M479.87-54q-87.96 0-165.74-33.42-77.79-33.43-135.53-91.18-57.75-57.74-91.18-135.66Q54-392.17 54-480.13q0-87.96 33.42-165.74 33.43-77.79 91.18-135.53 57.74-57.75 135.62-91.18Q392.09-906 480-906h36.22v340.7q24.82 10.69 40.8 33.52Q573-508.96 573-480.17q0 38.43-27.38 65.8Q518.24-387 479.79-387q-38.44 0-65.62-27.37Q387-441.74 387-480.17q0-28.79 15.98-51.61 15.98-22.83 40.8-33.52v-97.22Q378.22-650.39 336-599.76q-42.22 50.63-42.22 119.5 0 78.19 54.12 132.33 54.12 54.15 132.02 54.15 77.91 0 132.1-54.09 54.2-54.1 54.2-131.79 0-42.47-16.28-77.88-16.29-35.42-45.42-60.98l52.05-52.05q38.18 35.64 60.7 84.75 22.51 49.11 22.51 105.82 0 108.57-75.58 183.9-75.59 75.32-184.13 75.32-108.55 0-183.92-75.32-75.37-75.33-75.37-183.89 0-99.62 63.68-172.01 63.67-72.39 159.32-85.65v-93.22Q309.39-817.61 218.2-717.8 127-617.98 127-480.14q0 147.23 103.1 250.18Q333.19-127 480.14-127t249.9-103.02Q833-333.04 833-479.81q0-76.45-29.58-141.91-29.57-65.46-80.81-114.89l51.48-51.48q61.05 58.34 96.48 137.6Q906-571.23 906-479.73q0 87.82-33.42 165.6-33.43 77.79-91.18 135.53-57.74 57.75-135.66 91.18Q567.83-54 479.87-54Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M435-72.11q-49.67-7-95.99-25.36-46.31-18.36-86.55-49.55l49.21-49.74q31.76 24 65.29 37.26 33.52 13.26 68.04 19.02v68.37Zm90 0v-68.37q109.28-21.24 181.07-102.9 71.78-81.66 71.78-197.71 0-124.37-84.11-210.87t-208.72-86.5h-18.8L540.67-664l-49.74 49.74L332.2-773l158.73-158.74 49.74 49.26-75.41 75.65h19.28q75.48 0 141.34 28.72t114.98 78.56q49.12 49.83 77.24 116.29 28.12 66.46 28.12 142.17 0 142.39-90.8 245.07Q664.63-93.35 525-72.11ZM189.46-209.78q-28.96-38.72-47.82-86.3-18.86-47.57-25.62-100.01h68.89q5 37.76 18.38 72.17 13.38 34.4 36.14 64.16l-49.97 49.98Zm-73.44-276.31q6.52-50.71 25-97.29 18.48-46.58 48.44-87.77l50.21 48.26q-22.76 33-36.14 67.52-13.38 34.52-18.62 69.28h-68.89Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 811 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M527-72.11v-68.37q34.52-5.76 68.04-19.02 33.53-13.26 65.29-37.26l49.21 49.74q-40.24 31.19-86.55 49.55Q576.67-79.11 527-72.11Zm-90 0Q297.37-93.35 206.7-196.02q-90.68-102.68-90.68-245.07 0-75.71 28-142.17t77.12-116.29q49.12-49.84 114.98-78.56 65.86-28.72 141.34-28.72h19.28l-75.41-75.65 49.74-49.26L629.8-773 471.07-614.26 421.33-664l74.69-74.46h-19.04q-124.61 0-208.72 86.5t-84.11 210.87q0 116.05 71.78 197.71 71.79 81.66 181.07 102.9v68.37Zm335.54-137.67-49.97-49.98q22.76-29.76 36.14-64.16 13.38-34.41 18.38-72.17h69.13q-7 52.44-25.86 100.01-18.86 47.58-47.82 86.3Zm73.68-276.31h-69.13q-5.24-34.76-18.62-69.28t-36.14-67.52l50.21-48.26q29.96 41.19 48.44 87.77 18.48 46.58 25.24 97.29Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 815 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M854.7-689.22v504.13q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h504.13L854.7-689.22Zm-79.79 35.48L653.74-774.91H185.09v589.82h589.82v-468.65ZM479.76-250.09q43.24 0 73.74-30.26 30.5-30.27 30.5-73.5 0-43.24-30.26-73.74-30.27-30.5-73.5-30.5-43.24 0-73.74 30.27-30.5 30.26-30.5 73.5 0 43.23 30.26 73.73 30.27 30.5 73.5 30.5ZM238.09-578.91h358v-143h-358v143Zm-53-74.83v468.65-589.82 121.17Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 621 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M185.87-98.52v-681.39q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h429.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v681.39L480-224.17 185.87-98.52Zm79.22-120.39L480-309.18l214.91 90.27v-561H265.09v561Zm0-561h429.82-429.82Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 354 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M185.09-105.87q-32.93 0-56.08-23.14-23.14-23.15-23.14-56.08v-589.82q0-33.16 23.14-56.47 23.15-23.32 56.08-23.32h589.82q33.16 0 56.47 23.32 23.32 23.31 23.32 56.47v589.82q0 32.93-23.32 56.08-23.31 23.14-56.47 23.14H185.09Zm439.21-79.22h71l79.61-79.61v-40.39H744.3l-120 120ZM281-389.48l141-140 90 90L725.52-654 679-700.52l-167 167-90-90L234.48-436 281-389.48Zm-95.91 204.39h34.56l120-120h-71l-83.56 83.57v36.43Zm350.91 0 120-120h-71l-120 120h71Zm-159.87 0 120-120h-71l-120 120h71Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 609 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.09-.16-.26-.25-.44-.25-.06 0-.12.01-.17.03l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.06-.02-.12-.03-.18-.03-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.09.16.26.25.44.25.06 0 .12-.01.17-.03l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.06.02.12.03.18.03.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zm-1.98-1.71c.04.31.05.52.05.73 0 .21-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7.14 1.13zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M286.83-277h60v-205h-60v205Zm326.91 0h60v-420h-60v420ZM450-277h60v-118h-60v118Zm0-205h60v-60h-60v60ZM185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h589.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v589.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 452 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M145.09-145.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-79.22h669.82v-425.82H145.09v425.82ZM300-292l-42-42 103-104-104-104 43-42 146 146-146 146Zm190 4v-60h220v60H490Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 466 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M636.17-32.59 480.35-188.41l45.82-46.07 77.13 77.37v-137.32H356.93q-28.1 0-46.8-18.55-18.7-18.54-18.7-46.95V-600.3H74.02v-65.27h217.41v-137.32l-77.36 77.37-45.59-45.83 155.59-155.82 156.06 155.82-46.06 45.83-77.14-77.37v442.96h529.29v65.5H669.04v137.32l77.13-77.37L792-188.41 636.17-32.59ZM603.3-419.93V-600.3H416.93v-65.27H603.3q28.37 0 47.06 18.56 18.68 18.55 18.68 46.71v180.37H603.3Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 518 B

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M126-206.43 66.43-266 380-579.57 539.43-419.7l298-335 56.14 54.57-352.44 399.26L380-460.43l-254 254Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 231 B

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import threading
from queue import Queue
from typing import TYPE_CHECKING
from pydantic import BaseModel
@@ -25,14 +27,17 @@ class AutoUpdates:
def __init__(self, gui: BECDockArea):
self.gui = gui
self._default_dock = None
self._default_fig = None
def start_default_dock(self):
"""
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._default_dock = self.gui.add_dock(self.dock_name)
self._default_dock.add_widget("BECFigure")
self._default_fig = self._default_dock.widget_list[0]
@staticmethod
def get_scan_info(msg) -> ScanInfo:
@@ -60,15 +65,9 @@ class AutoUpdates:
"""
Get the default figure from the GUI.
"""
dock = self.gui.panels.get(self.dock_name, [])
if not dock:
return None
widgets = dock.widget_list
if not widgets:
return None
return widgets[0]
return self._default_fig
def run(self, msg):
def do_update(self, msg):
"""
Run the update function if enabled.
"""
@@ -77,10 +76,9 @@ class AutoUpdates:
if msg.status != "open":
return
info = self.get_scan_info(msg)
self.handler(info)
return self.handler(info)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
def get_selected_device(self, monitored_devices, selected_device):
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
@@ -97,14 +95,11 @@ class AutoUpdates:
Default update function.
"""
if info.scan_name == "line_scan" and info.scan_report_devices:
self.simple_line_scan(info)
return
return self.simple_line_scan(info)
if info.scan_name == "grid_scan" and info.scan_report_devices:
self.simple_grid_scan(info)
return
return self.simple_grid_scan(info)
if info.scan_report_devices:
self.best_effort(info)
return
return self.best_effort(info)
def simple_line_scan(self, info: ScanInfo) -> None:
"""
@@ -114,12 +109,19 @@ class AutoUpdates:
if not fig:
return
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:
return
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
yield fig.clear_all()
yield fig.plot(
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:
"""
@@ -130,12 +132,18 @@ class AutoUpdates:
return
dev_x = info.scan_report_devices[0]
dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
fig.clear_all()
plt = fig.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
selected_device = yield self.gui.selected_device
dev_z = self.get_selected_device(info.monitored_devices, selected_device)
yield fig.clear_all()
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:
"""
@@ -145,9 +153,16 @@ class AutoUpdates:
if not fig:
return
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:
return
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
yield fig.clear_all()
yield fig.plot(
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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -6,65 +6,43 @@ import json
import os
import select
import subprocess
import sys
import threading
import time
import uuid
from functools import wraps
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
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 qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
from bec_widgets.cli.rpc.rpc_base import RPCBase
if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from bec_widgets.cli.client import BECDockArea, BECFigure
from bec_lib.serialization import MsgpackSerialization
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
from bec_widgets.utils.bec_dispatcher import BECDispatcher
else:
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
def rpc_call(func):
def _filter_output(output: str) -> str:
"""
A decorator for calling a function on the server.
Args:
func: The function to call.
Returns:
The result of the function call.
Filter out the output from the process.
"""
@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.gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
return wrapper
if "IMKClient" in output:
# only relevant on macOS
# see https://discussions.apple.com/thread/255761734?sortBy=rank
return ""
return output
def _get_output(process, logger) -> None:
@@ -80,12 +58,13 @@ def _get_output(process, logger) -> None:
if stream in readylist:
buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n")
output = _filter_output(output)
if output:
log_func[stream](output)
buf.clear()
buf.append(remaining)
except Exception as e:
print(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:
@@ -97,14 +76,15 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
process will not be captured.
"""
# 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__, "--hide"]
if config:
if isinstance(config, dict):
config = json.dumps(config)
command.extend(["--config", config])
command.extend(["--config", str(config)])
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
@@ -130,14 +110,85 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
return process, process_output_processing_thread
class BECGuiClientMixin:
class RepeatTimer(threading.Timer):
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
@contextmanager
def wait_for_server(client):
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
### ----------------------------
### NOTE
### it is far easier to extend the 'delete' method on the client side,
### to know when the client is deleted, rather than listening to server
### to get notified. However, 'generate_cli.py' cannot add extra stuff
### in the generated client module. So, here a class with the same name
### is created, and client module is patched.
class BECDockArea(client.BECDockArea):
def delete(self):
if self is BECGuiClient._top_level["main"].widget:
raise RuntimeError("Cannot delete main window")
super().delete()
try:
del BECGuiClient._top_level[self._gui_id]
except KeyError:
# if a dock area is not at top level
pass
client.BECDockArea = BECDockArea
### ----------------------------
@dataclass
class WidgetDesc:
title: str
widget: BECDockArea
class BECGuiClient(RPCBase):
_top_level = {}
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._auto_updates_enabled = True
self._auto_updates = None
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
@property
def windows(self):
return self._top_level
@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:
eps = imd.entry_points(group="bec.widgets.auto_updates")
@@ -150,7 +201,7 @@ class BECGuiClientMixin:
continue
return ep.load()(gui=self)
except Exception as e:
print(f"Error loading auto update script from plugin: {str(e)}")
logger.error(f"Error loading auto update script from plugin: {str(e)}")
return None
@property
@@ -158,205 +209,151 @@ class BECGuiClientMixin:
"""
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
def selected_device(self, device: str | DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._selected_device = device.name
self._client.connector.set_and_publish(
MessageEndpoints.gui_auto_update_config(self._gui_id),
messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
)
elif isinstance(device, str):
self._selected_device = device
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:
self._client.connector.register(
self._target_endpoint, cb=self._handle_msg_update, parent=self
)
self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
@staticmethod
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
if parent.auto_updates is not None:
def _handle_msg_update(self, msg: MessageObject) -> None:
if self.auto_updates is not None:
# pylint: disable=protected-access
parent._update_script_msg_parser(msg.value)
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
self.auto_updates.run(msg)
if self._auto_updates_enabled:
return self.auto_updates.do_update(msg)
def show(self) -> None:
def _gui_post_startup(self):
self._top_level["main"] = WidgetDesc(
title="BEC Widgets", widget=BECDockArea(gui_id=self._gui_id)
)
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._do_show_all()
self._gui_started_event.set()
def start_server(self, wait=False) -> 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:
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._gui_id, self.__class__, self._client._service_config.config
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
time.sleep(1)
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):
return self.start_server()
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
for window in self._top_level.values():
window.widget.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")
for window in self._top_level.values():
window.widget.hide()
def show(self):
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):
return self.hide_all()
@property
def main(self):
"""Return client to main dock area (in main window)"""
with wait_for_server(self):
return self._top_level["main"].widget
def new(self, title):
"""Ask main window to create a new top-level dock area"""
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", title)
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
return widget
def close(self) -> None:
"""
Close the gui window.
"""
self._top_level.clear()
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
self._gui_started_timer.join()
if self._process is None:
return
self._client.shutdown()
if self._process:
logger.success("Stopping GUI...")
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
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 QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECDispatcher().client
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
self._parent = parent
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:
redis_msg = QtRedisMessageWaiter(
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if wait_for_rpc_response:
response = redis_msg.wait(timeout)
if response is None:
raise RPCResponseTimeoutError(request_id, timeout)
# get class name
if not response.accepted:
raise ValueError(response.message["error"])
msg_result = response.message.get("result")
return self._create_widget_from_msg_result(msg_result)
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))
return heart is not None

View File

@@ -8,9 +8,10 @@ import sys
import black
import isort
from qtpy.QtCore import Property as QtProperty
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):
from typing import get_overloads
@@ -30,10 +31,11 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from __future__ import annotations
import enum
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"""
@@ -41,14 +43,21 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
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:
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__)
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__)
self.write_client_enum(rpc_top_level_classes)
@@ -79,22 +88,40 @@ class Widgets(str, enum.Enum):
class_name = cls.__name__
# Generate the content
if cls.__name__ == "BECDockArea":
if class_name == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase, BECGuiClientMixin):"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
self.content += """
is_property_setter = False
obj = getattr(cls, method, None)
if obj is None:
obj = getattr(cls, method.split(".setter")[0], None)
is_property_setter = True
method = method.split(".setter")[0]
if obj is None:
raise AttributeError(
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
self.content += f"""
@{method}.setter
@rpc_call"""
else:
self.content += """
@property
@rpc_call"""
sig = str(inspect.signature(obj.fget))
doc = inspect.getdoc(obj.fget)
else:
@@ -157,7 +184,7 @@ def main():
current_path = os.path.dirname(__file__)
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.generate_client(rpc_classes)

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING
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.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, config: dict = 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._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(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

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from threading import Lock
from weakref import WeakValueDictionary

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector
@@ -26,10 +28,10 @@ class RPCWidgetHandler:
Returns:
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")
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
clss = get_custom_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""

View File

@@ -1,39 +1,63 @@
from __future__ import annotations
import inspect
import functools
import json
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from qtpy.QtCore import Qt, QTimer
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.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
messages = lazy_import("bec_lib.messages")
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
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:
def __init__(
self,
gui_id: str = None,
gui_id: str,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
@@ -47,24 +71,29 @@ class BECWidgetsCLIServer:
)
# Setup QTimer for heartbeat
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
print(e)
self.send_response(request_id, False, {"error": str(e)})
else:
self.send_response(request_id, True, {"result": res})
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
logger.error(f"Error while executing RPC instruction: {e}")
self.send_response(request_id, False, {"error": str(e)})
else:
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):
self.client.connector.set_and_publish(
@@ -81,16 +110,17 @@ class BECWidgetsCLIServer:
return obj
def run_rpc(self, obj, method, args, kwargs):
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
res = method_obj
else:
sig = inspect.signature(method_obj)
if sig.parameters:
res = method_obj(*args, **kwargs)
if not args:
res = method_obj
else:
res = method_obj()
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
@@ -111,16 +141,18 @@ class BECWidgetsCLIServer:
return obj
def emit_heartbeat(self):
if self._shutdown_event is False:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=1,
)
logger.trace(f"Emitting heartbeat for {self.gui_id}")
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self._shutdown_event = True
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
self.gui.close()
self.client.shutdown()
@@ -128,15 +160,16 @@ class BECWidgetsCLIServer:
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
def write(self, buffer):
for line in buffer.rstrip().splitlines():
line = line.rstrip()
if line:
self._log_func(line)
self._buffer.append(buffer)
def flush(self):
return
lines, _, remaining = "".join(self._buffer).rpartition("\n")
if lines:
self._log_func(lines)
self._buffer = [remaining]
def close(self):
return
@@ -153,12 +186,12 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# if no config is provided, use the default config
service_config = ServiceConfig()
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
# bec_logger.configure(
# service_config.redis,
# QtRedisConnector,
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
return server
@@ -169,36 +202,46 @@ def main():
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QMainWindow
from qtpy.QtWidgets import QApplication
import bec_widgets
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(
"--gui_class",
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
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()
if args.gui_class == "BECFigure":
gui_class = BECFigure
elif args.gui_class == "BECDockArea":
bec_logger.level = bec_logger.LOGLEVEL.INFO
if args.hide:
# 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
elif args.gui_class == "BECFigure":
gui_class = BECFigure
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECFigure
gui_class = BECDockArea
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
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__)
icon = QIcon()
icon.addFile(
@@ -206,22 +249,33 @@ def main():
size=QSize(48, 48),
)
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
# store gui id within QApplication object, to make it available to all widgets
app.gui_id = args.id
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 Widgets")
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
if not args.hide:
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
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()
signal.signal(signal.SIGINT, sigint_handler)
@@ -230,5 +284,5 @@ def main():
sys.exit(app.exec())
if __name__ == "__main__": # pragma: no cover
if __name__ == "__main__":
main()

View File

@@ -80,7 +80,7 @@ def main(): # pragma: no cover
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-Dark.png"), size=QSize(48, 48)
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
main_window = BECGeneralApp()

View File

@@ -2,12 +2,12 @@ import os
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from bec_qthemes import material_icon
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
QHBoxLayout,
QPushButton,
QSplitter,
QTabWidget,
QVBoxLayout,
@@ -16,9 +16,11 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
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.plot_base import PlotBase
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -46,14 +48,23 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w7": self.w7,
"w8": self.w8,
"w9": self.w9,
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
# "bar": self.bar,
# "cm": self.colormap,
"im": self.im,
"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,
}
)
@@ -78,11 +89,34 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
second_tab_layout.addWidget(self.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, "PltoBase")
tab_widget.setCurrentIndex(3)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
# Some buttons for layout testing
self.btn1 = QPushButton("Button 1")
self.btn2 = QPushButton("Button 2")
self.btn3 = QPushButton("Button 3")
self.btn4 = QPushButton("Button 4")
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
# add stuff to figure
self._init_figure()
@@ -92,15 +126,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.setWindowTitle("Jupyter Console Window")
def _init_figure(self):
self.w1 = self.figure.plot(
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 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
self.w1.set(
title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position",
@@ -162,18 +188,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("eiger")
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.wf = self.d2.add_widget("BECFigure", row=0, col=0)
# 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.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
self.dock.save_state()
@@ -199,10 +219,7 @@ if __name__ == "__main__": # pragma: no cover
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark")
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "terminal_icon.png"), size=QSize(48, 48)
)
icon = material_icon("terminal", color="#434343", filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
@@ -211,6 +228,7 @@ if __name__ == "__main__": # pragma: no cover
win = JupyterConsoleWindow()
win.show()
win.resize(1200, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -16,7 +16,6 @@ class TicTacToe(QWidget): # pragma: no cover
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
print("TicTac HERE !!!!!!")
def minimumSizeHint(self):
return QSize(200, 200)

View File

@@ -3,11 +3,11 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
import bec_widgets
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu import TicTacToeTaskMenuFactory
from bec_widgets.utils.bec_designer import designer_material_icon
DOM_XML = """
<ui language='c++'>
@@ -46,8 +46,7 @@ class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "Games"
def icon(self):
icon_path = os.path.join(MODULE_PATH, "assets", "designer_icons", "games.png")
return QIcon(icon_path)
return designer_material_icon("sports_esports")
def includeFile(self):
return "tictactoe"

View File

@@ -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())

View File

@@ -0,0 +1,268 @@
import time
from types import SimpleNamespace
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import get_accent_colors
class LedLabel(QLabel):
success_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:1, y2:1, stop:0 %s, stop:1 %s);"
emergency_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:0.92, y2:0.988636, stop:0 %s, stop:1 %s);"
warning_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.232, y1:0.272, x2:0.98, y2:0.959773, stop:0 %s, stop:1 %s);"
default_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.04, y1:0.0565909, x2:0.799, y2:0.795, stop:0 %s, stop:1 %s);"
def __init__(self, parent=None):
super().__init__(parent)
self.palette = get_accent_colors()
if self.palette is None:
# no theme!
self.palette = SimpleNamespace(
default=QColor("blue"),
success=QColor("green"),
warning=QColor("orange"),
emergency=QColor("red"),
)
self.setState("default")
self.setFixedSize(20, 20)
def setState(self, state: str):
match state:
case "success":
r, g, b, a = self.palette.success.getRgb()
self.setStyleSheet(
LedLabel.success_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "default":
r, g, b, a = self.palette.default.getRgb()
self.setStyleSheet(
LedLabel.default_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "warning":
r, g, b, a = self.palette.warning.getRgb()
self.setStyleSheet(
LedLabel.warning_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case "emergency":
r, g, b, a = self.palette.emergency.getRgb()
self.setStyleSheet(
LedLabel.emergency_led
% (
f"rgba({r},{g},{b},{a})",
f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})",
)
)
case unknown_state:
raise ValueError(
f"Unknown state {repr(unknown_state)}, must be one of default, success, warning or emergency"
)
class PopupDialog(QDialog):
def __init__(self, content_widget):
self.parent = content_widget.parent()
self.content_widget = content_widget
super().__init__(self.parent)
self.setAttribute(Qt.WA_DeleteOnClose)
self.content_widget.setParent(self)
QVBoxLayout(self)
self.layout().addWidget(self.content_widget)
self.content_widget.setVisible(True)
def closeEvent(self, event):
self.content_widget.setVisible(False)
self.content_widget.setParent(self.parent)
self.done(True)
class CompactPopupWidget(QWidget):
"""Container widget, that can display its content or have a compact form,
in this case clicking on a small button pops the contained widget up.
In the compact form, a LED-like indicator shows a status indicator.
"""
expand = Signal(bool)
def __init__(self, parent=None, layout=QVBoxLayout):
super().__init__(parent)
self._popup_window = None
self._expand_popup = True
QVBoxLayout(self)
self.compact_view_widget = QWidget(self)
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
QHBoxLayout(self.compact_view_widget)
self.compact_view_widget.layout().setSpacing(0)
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
self.compact_view_widget.layout().addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
)
self.compact_label = QLabel(self.compact_view_widget)
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QPushButton(self.compact_view_widget)
self.compact_show_popup.setFlat(True)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)
self.compact_view_widget.layout().addWidget(self.compact_label)
self.compact_view_widget.layout().addWidget(self.compact_status)
self.compact_view_widget.layout().addWidget(self.compact_show_popup)
self.compact_view_widget.setVisible(False)
self.layout().addWidget(self.compact_view_widget)
self.container = QWidget(self)
self.layout().addWidget(self.container)
self.container.setVisible(True)
layout(self.container)
self.layout = self.container.layout()
self.compact_show_popup.clicked.connect(self.show_popup)
def set_global_state(self, state: str):
"""Set the LED-indicator state
The LED indicator represents the 'global' state. State can be one of the
following: "default", "success", "warning", "emergency"
"""
self.compact_status.setState(state)
def show_popup(self):
"""Display the contained widgets in a popup dialog"""
if self._expand_popup:
# show popup
self._popup_window = PopupDialog(self.container)
self._popup_window.show()
self._popup_window.finished.connect(lambda: self.expand.emit(False))
self.expand.emit(True)
else:
if self.compact_view:
# expand in place
self.compact_view = False
self.compact_view_widget.setVisible(True)
self.compact_label.setVisible(False)
self.compact_status.setVisible(False)
self.compact_show_popup.setIcon(
material_icon(
icon_name="collapse_content", size=(10, 10), convert_to_pixmap=False
)
)
self.expand.emit(True)
else:
# back to compact form
self.compact_label.setVisible(True)
self.compact_status.setVisible(True)
self.compact_show_popup.setIcon(
material_icon(
icon_name="expand_content", size=(10, 10), convert_to_pixmap=False
)
)
self.compact_view = True
self.expand.emit(False)
def setSizePolicy(self, size_policy1, size_policy2=None):
# setting size policy on the compact popup widget will set
# the policy for the container, and for itself
if size_policy2 is None:
# assuming first form: setSizePolicy(QSizePolicy)
self.container.setSizePolicy(size_policy1)
QWidget.setSizePolicy(self, size_policy1)
else:
self.container.setSizePolicy(size_policy1, size_policy2)
QWidget.setSizePolicy(self, size_policy1, size_policy2)
def addWidget(self, widget):
"""Add a widget to the popup container
The popup container corresponds to the "full view" (not compact)
The widget is reparented to the container, and added to the container layout
"""
widget.setParent(self.container)
self.container.layout().addWidget(widget)
@Property(bool)
def compact_view(self):
return self.compact_label.isVisible()
@compact_view.setter
def compact_view(self, set_compact: bool):
"""Sets the compact form
If set_compact is True, the compact view is displayed ; otherwise,
the full view is displayed. This is handled by toggling visibility of
the container widget or the compact view widget.
"""
if set_compact:
self.compact_view_widget.setVisible(True)
self.container.setVisible(False)
QWidget.setSizePolicy(self, QSizePolicy.Fixed, QSizePolicy.Fixed)
else:
self.compact_view_widget.setVisible(False)
self.container.setVisible(True)
QWidget.setSizePolicy(self, self.container.sizePolicy())
if self.parentWidget():
self.parentWidget().adjustSize()
else:
self.adjustSize()
@Property(str)
def label(self):
return self.compact_label.text()
@label.setter
def label(self, compact_label_text: str):
"""Set the label text associated to the compact view"""
self.compact_label.setText(compact_label_text)
@Property(str)
def tooltip(self):
return self.compact_label.toolTip()
@tooltip.setter
def tooltip(self, tooltip: str):
"""Set the tooltip text associated to the compact view"""
self.compact_label.setToolTip(tooltip)
self.compact_status.setToolTip(tooltip)
@Property(bool)
def expand_popup(self):
return self._expand_popup
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()

View File

@@ -2,9 +2,93 @@ import functools
import sys
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
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
"""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:
return method(*args, **kwargs)
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
@@ -91,6 +181,12 @@ class _ErrorPopupUtility(QObject):
msg.setMinimumHeight(400)
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:
"""
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)
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):
if popup_error or self.enable_error_popup:
error_message = traceback.format_exception(exctype, value, tb)
self.error_occurred.emit(
"Method error" if popup_error else "Application Error",
"".join(error_message),
self.get_error_message(exctype, value, tb),
self.parent(),
)
else:

View File

@@ -0,0 +1,183 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class PaletteViewer(BECWidget, QWidget):
"""
This class is a widget that displays current palette colors.
"""
ICON_NAME = "palette"
def __init__(self, *args, parent=None, **kwargs):
super().__init__(*args, theme_update=True, **kwargs)
QWidget.__init__(self, parent=parent)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)
layout.addWidget(dark_mode_button)
# Create a scroll area to hold the color boxes
scroll_area = QScrollArea(self)
scroll_area.setWidgetResizable(True)
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create a frame to hold the color boxes
self.frame = QFrame(self)
self.frame_layout = QGridLayout(self.frame)
self.frame_layout.setSpacing(0)
self.frame_layout.setContentsMargins(0, 0, 0, 0)
scroll_area.setWidget(self.frame)
layout.addWidget(scroll_area)
self.setLayout(layout)
self.update_palette()
def apply_theme(self, theme) -> None:
"""
Apply the theme to the widget.
Args:
theme (str): The theme to apply.
"""
self.update_palette()
def clear_palette(self) -> None:
"""
Clear the palette colors from the frame.
Recursively removes all widgets and layouts in the frame layout.
"""
# Iterate over all items in the layout in reverse to safely remove them
for i in reversed(range(self.frame_layout.count())):
item = self.frame_layout.itemAt(i)
# If the item is a layout, clear its contents
if isinstance(item, QHBoxLayout):
# Recursively remove all widgets from the layout
for j in reversed(range(item.count())):
widget = item.itemAt(j).widget()
if widget:
item.removeWidget(widget)
widget.deleteLater()
self.frame_layout.removeItem(item)
# If the item is a widget, remove and delete it
elif item.widget():
widget = item.widget()
self.frame_layout.removeWidget(widget)
widget.deleteLater()
def update_palette(self) -> None:
"""
Update the palette colors in the frame.
"""
self.clear_palette()
palette_label = QLabel("Palette Colors (e.g. palette.windowText().color())")
palette_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(palette_label, 0, 0)
palette = get_theme_palette()
# Add the palette colors (roles) to the frame
palette_roles = [
palette.windowText,
palette.toolTipText,
palette.placeholderText,
palette.text,
palette.buttonText,
palette.highlight,
palette.link,
palette.light,
palette.midlight,
palette.mid,
palette.shadow,
palette.button,
palette.brightText,
palette.toolTipBase,
palette.alternateBase,
palette.dark,
palette.base,
palette.window,
palette.highlightedText,
palette.linkVisited,
]
offset = 1
for i, pal in enumerate(palette_roles):
i += offset
color = pal().color()
label_layout = QHBoxLayout()
color_label = QLabel(f"{pal().color().name()} ({pal.__name__})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i, 0)
# add a horizontal spacer
spacer = QLabel()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self.frame_layout.addWidget(spacer, i + 1, 0)
accent_colors_label = QLabel("Accent Colors (e.g. accent_colors.default)")
accent_colors_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(accent_colors_label, i + 2, 0)
accent_colors = get_accent_colors()
items = [
(accent_colors.default, "default"),
(accent_colors.success, "success"),
(accent_colors.warning, "warning"),
(accent_colors.emergency, "emergency"),
(accent_colors.highlight, "highlight"),
]
offset = len(palette_roles) + 2
for i, (color, name) in enumerate(items):
i += offset
label_layout = QHBoxLayout()
color_label = QLabel(f"{color.name()} ({name})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i + 2, 0)
def background_label_with_clipboard(self, color) -> QLabel:
"""
Create a label with a background color that copies the color to the clipboard when clicked.
Args:
color (QColor): The color to display in the background.
Returns:
QLabel: The label with the background color.
"""
button = QLabel()
button.setStyleSheet(f"QLabel {{ background-color: {color.name()}; }}")
button.setToolTip("Click to copy color to clipboard")
button.setCursor(Qt.PointingHandCursor)
button.mousePressEvent = lambda event: QApplication.clipboard().setText(color.name())
return button
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
viewer = PaletteViewer()
viewer.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,47 @@
from bec_lib.serialization import MsgpackSerialization
from bec_lib.utils import lazy_import_from
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})

View File

@@ -0,0 +1,180 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, 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")
self.update_style()
# Create a layout for the frame
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the PlotWidget if applicable
if isinstance(content_widget, pg.PlotWidget):
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()
# Update PlotWidget's background color and axis styles if applicable
if isinstance(self.content_widget, pg.PlotWidget):
self.apply_plot_widget_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 */
}}
"""
)
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.PlotWidget):
# Sync PlotWidget's background color with the RoundedFrame's background color
self.content_widget.setBackground(self.background_color)
# Calculate contrast-optimized axis and label colors
if self.background_color == "#e9ecef": # Light mode
label_color = "#000000"
axis_color = "#666666"
else: # Dark mode
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# Apply axis label and tick colors
plot_item = self.content_widget.getPlotItem()
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)
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
PlotWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
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.PlotWidget()
plot1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot2 = pg.PlotWidget()
plot2.plot([1, 2, 4, 8, 16, 32], pen="r")
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
round = RoundedFrame()
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(round)
self.setLayout(layout)
# Simulate theme change after 2 seconds
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()

View File

@@ -106,3 +106,14 @@ class SettingsDialog(QDialog):
Apply the changes made in the settings widget without closing the dialog.
"""
self.widget.accept_changes()
def cleanup(self):
"""
Cleanup the dialog.
"""
self.button_box.close()
self.button_box.deleteLater()
def closeEvent(self, event):
self.cleanup()
super().closeEvent(event)

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.waveform.waveform_widget import BECWaveformWidget
self.plot = BECWaveformWidget()
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())

View File

@@ -1,11 +1,27 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
import sys
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Dict, List, Literal, Tuple
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QIcon
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMenu, QToolBar, QToolButton, QWidget
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QSizePolicy,
QToolBar,
QToolButton,
QWidget,
)
import bec_widgets
@@ -18,7 +34,7 @@ class ToolBarAction(ABC):
Args:
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.
"""
@@ -44,9 +60,7 @@ class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
self.separator = QToolButton()
self.separator.setFixedSize(2, 22)
toolbar.addWidget(self.separator)
toolbar.addSeparator()
class IconAction(ToolBarAction):
@@ -70,6 +84,65 @@ class IconAction(ToolBarAction):
toolbar.addAction(self.action)
class MaterialIconAction(ToolBarAction):
"""
Action with a Material icon for the toolbar.
Args:
icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
Defaults to None.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
checkable: bool = False,
filled: bool = False,
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.filled = filled
self.color = color
# Generate the icon
self.icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
# Immediately create an QAction with the given parent
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):
"""
Action for selecting a device in a combobox.
@@ -77,7 +150,6 @@ class DeviceSelectionAction(ToolBarAction):
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
@@ -98,6 +170,63 @@ class DeviceSelectionAction(ToolBarAction):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
super().__init__(parent)
self.label = label
self.widget = widget
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
if self.label is not None:
label_widget = QLabel(f"{self.label}")
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox):
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
else:
self.widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
layout.addWidget(self.widget)
toolbar.addWidget(container)
@staticmethod
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()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
return max_width + 60
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
@@ -106,7 +235,6 @@ class ExpandableMenuAction(ToolBarAction):
label (str): The label for 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.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
@@ -133,10 +261,12 @@ class ExpandableMenuAction(ToolBarAction):
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
if action.icon_path:
if hasattr(action, "icon_path"):
icon = QIcon()
icon.addFile(action.icon_path, size=QSize(20, 20))
sub_action.setIcon(icon)
elif hasattr(action, "get_icon"):
sub_action.setIcon(action.get_icon())
sub_action.setCheckable(action.checkable)
menu.addAction(sub_action)
self.widgets[action_id] = sub_action
@@ -144,13 +274,64 @@ class ExpandableMenuAction(ToolBarAction):
toolbar.addWidget(button)
class ToolbarBundle:
"""
Represents a bundle of toolbar actions, keyed by action_id.
Allows direct dictionary-like access: self.actions["some_id"] -> ToolBarAction object.
"""
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 you passed in a list of tuples, load them into the dictionary
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 (list[ToolBarAction], optional): A list of action creators to populate 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.
color (str, optional): The background color of the toolbar. Defaults to "black".
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)" - transparent background.
"""
def __init__(
@@ -158,31 +339,334 @@ class ModularToolBar(QToolBar):
parent=None,
actions: dict | None = None,
target_widget=None,
color: str = "rgba(255, 255, 255, 0)",
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent)
self.widgets = defaultdict(dict)
self.set_background_color(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:
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.
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.
"""
self.clear()
self.toolbar_items.clear() # Reset the order tracking
for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget)
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, color: str):
self.setStyleSheet(f"QToolBar {{ background: {color}; }}")
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.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
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 in the toolbar.
Args:
new_color (str | tuple | QColor): The new color for the icons.
"""
for action in self.widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
# Refresh the icon
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 to the toolbar dynamically.
Args:
action_id (str): Unique identifier for the action.
action (ToolBarAction): The action to add to the toolbar.
target_widget (QWidget): The target widget for the action.
"""
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() # Update separators after adding the action
def hide_action(self, action_id: str):
"""
Hides a specific action on the toolbar.
Args:
action_id (str): Unique identifier for the action to hide.
"""
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 isinstance(action.action, QAction):
action.action.setVisible(False)
self.update_separators() # Update separators after hiding the action
def show_action(self, action_id: str):
"""
Shows a specific action on the toolbar.
Args:
action_id (str): Unique identifier for the action to show.
"""
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 isinstance(action.action, QAction):
action.action.setVisible(True)
self.update_separators() # Update separators after showing the action
def add_bundle(self, bundle: ToolbarBundle, target_widget: QWidget):
"""
Adds a bundle of actions to the toolbar, separated by a separator.
Args:
bundle (ToolbarBundle): The bundle to add.
target_widget (QWidget): The target widget for the actions.
"""
if bundle.bundle_id in self.bundles:
raise ValueError(f"ToolbarBundle with ID '{bundle.bundle_id}' already exists.")
# Add a separator before the bundle (but not to first one)
if self.toolbar_items:
sep = SeparatorAction()
sep.add_to_toolbar(self, target_widget)
self.toolbar_items.append(("separator", None))
# Add each action in the bundle
for action_id, action_obj in bundle.actions.items():
action_obj.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action_obj
# Register the bundle
self.bundles[bundle.bundle_id] = list(bundle.actions.keys())
self.toolbar_items.append(("bundle", bundle.bundle_id))
self.update_separators() # Update separators after adding the bundle
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
Args:
event(QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
# Iterate through the toolbar items in order
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)
# Connect the triggered signal after all actions are added
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds a set of bundle actions to the context menu.
Args:
menu (QMenu): The context menu to which the actions are added.
bundle_id (str): The identifier for the bundle.
"""
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 toggling of toolbar actions from the context menu."""
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 on the toolbar.
Args:
action_id(str): Unique identifier for the action to toggle.
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 isinstance(tool_action.action, QAction):
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no actions next to them.
"""
toolbar_actions = self.actions()
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
# Find the previous visible action
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
# Find the next visible action
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
# Determine if the separator should be hidden
# Hide if both previous and next visible actions are separators or non-existent
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)
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)
# Create a modular toolbar
self.toolbar = ModularToolBar(parent=self, target_widget=self)
self.addToolBar(self.toolbar)
# Example: Add a single bundle
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, 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)
# Another bundle
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=True, parent=self
)
help_action = MaterialIconAction(
icon_name="help", tooltip="Help", checkable=True, 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)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

234
bec_widgets/tests/utils.py Normal file
View File

@@ -0,0 +1,234 @@
from unittest.mock import MagicMock
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
class FakeDevice(BECDevice):
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.signals[self.name]["value"] = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
class FakePositioner(BECPositioner):
def __init__(
self,
name,
enabled=True,
limits=None,
read_value=1.0,
readout_priority=ReadoutPriority.MONITORED,
):
super().__init__(name=name)
# self.limits = limits if limits is not None else [0.0, 0.0]
self.read_value = read_value
self.setpoint_value = read_value
self.motor_is_moving_value = 0
self._enabled = enabled
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
}
}
self.signals = {
self.name: {"value": self.read_value},
f"{self.name}_setpoint": {"value": self.setpoint_value},
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
}
@property
def readout_priority(self):
return self._readout_priority
@readout_priority.setter
def readout_priority(self, value):
self._readout_priority = value
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
@property
def limits(self) -> tuple[float, float]:
return self._limits
@limits.setter
def limits(self, value: tuple[float, float]):
self._limits = value
def __contains__(self, item):
return item == self.name
@property
def _hints(self):
return [self.name]
def set_value(self, fake_value: float = 1.0) -> None:
"""
Setup fake value for device readout
Args:
fake_value(float): Desired fake value
"""
self.read_value = fake_value
def describe(self) -> dict:
"""
Get the description of the device
Returns:
dict: Description of the device
"""
return self.description
@property
def precision(self):
return 3
def set_read_value(self, value):
self.read_value = value
def read(self):
return self.signals
def set_limits(self, limits):
self.limits = limits
def move(self, value, relative=False):
"""Simulates moving the device to a new position."""
if relative:
self.read_value += value
else:
self.read_value = value
# Respect the limits
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
@property
def readback(self):
return MagicMock(get=MagicMock(return_value=self.read_value))
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name, enabled=True):
super().__init__(name, enabled)
class DMMock:
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devives(self, devices: list):
for device in devices:
self.devices[device.name] = device
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
FakePositioner("aptrx", limits=None, read_value=4.0),
FakePositioner("aptry", limits=None, read_value=5.0),
FakeDevice("gauss_bpm"),
FakeDevice("gauss_adc1"),
FakeDevice("gauss_adc2"),
FakeDevice("gauss_adc3"),
FakeDevice("bpm4i"),
FakeDevice("bpm3a"),
FakeDevice("bpm3i"),
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
FakeDevice("waveform1d"),
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
Positioner("test", limits=[-10, 10], read_value=2.0),
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

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