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

Compare commits

..

15 Commits

Author SHA1 Message Date
b1ef2b20d2 fix(motor_map): fixed bug with residual trace after changing motors 2024-07-02 20:36:20 +02:00
055ad0f22f refactor(motor_widgets_settings): moved to separate folder 2024-07-02 20:27:48 +02:00
8827ab7ff7 feat(motor_map_widget): plugin added 2024-07-02 20:20:17 +02:00
9f33a5c81f feat(motor_map_widget): complete toolbar 2024-07-02 20:08:08 +02:00
a40662071b feat(toolbar):colors adjusted 2024-07-02 19:53:59 +02:00
f4f92202c1 feat(color_button): patched ColorButton from pyqtgraph to be able to be opened in another QDialog 2024-07-02 19:52:46 +02:00
aa33d04444 feat(motor_map_widget): combobox in toolbar changes color if changed 2024-07-02 15:49:32 +02:00
9e4d407893 refactor(toolbar): cleanup 2024-07-02 15:48:31 +02:00
55be644bf3 refactor(motor_map_widget): moved to top level 2024-07-02 15:48:31 +02:00
5bba788153 feat(motor_map): method to reset history trace 2024-07-02 14:14:10 +02:00
6fe568f83b refactor(motor_map_widget): slots changed 2024-07-02 14:14:09 +02:00
e7b3109493 feat(axis_setting): general axis setting dialog for bec figure subplots 2024-07-02 14:12:06 +02:00
fb17bf66cc feat(widget_io): added method to check and adjust limits of spinboxes 2024-07-02 14:12:06 +02:00
1f2f4824bf fix(widget_io): added option to set value of combobox by str 2024-07-02 14:12:06 +02:00
51f54ca22e feat(motor_widget): Motor Widget with toolbar WIP 2024-07-02 14:12:06 +02:00
350 changed files with 6207 additions and 14875 deletions

View File

@@ -43,20 +43,13 @@ stages:
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-repos: &install-repos
- pip install -e ./ophyd_devices
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *install-qt-webengine-deps
before_script:
@@ -138,9 +131,10 @@ tests:
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
@@ -150,9 +144,6 @@ tests:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
test-matrix:
parallel:
@@ -163,6 +154,7 @@ test-matrix:
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt5"
- "pyqt6"
stage: AdditionalTests
@@ -175,9 +167,11 @@ test-matrix:
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,$QT_PCKG]
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
end-2-end-conda:
stage: End2End
@@ -199,9 +193,10 @@ end-2-end-conda:
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- cd ../
- pip install -e ./ophyd_devices
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order

View File

@@ -3,7 +3,7 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
extension-pkg-allow-list=PyQt5, pyqtgraph
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may

View File

@@ -1,161 +1,155 @@
# CHANGELOG
## v0.99.13 (2024-08-30)
### Documentation
* docs: minor updates to the widget tutorial ([`ec9c8f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ec9c8f29633364c45ebd998a5411d428c1ce488d))
* docs(widget tutorial): step by step guide added ([`b32ced8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b32ced85fff628a9e1303a781630cdae3865238e))
### Fix
* fix(dark mode button): fixed dark mode button state for external updates, including auto ([`a3110d9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a3110d98147295dcb1f9353f9aaf5461cba9232a))
## v0.99.12 (2024-08-29)
### Fix
* fix(toolbar): widget action added ([`2efd487`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2efd48736cbe04e84533f7933c552ea8274e2162))
* fix(reset_button): reset button added ([`6ed1efc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ed1efc6af193908f70aa37fb73157d2ca6a62f4))
* fix(abort_button): abort button added; some minor fixes ([`a568633`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a568633c3206a8c26069d140f2d9a548bf4124b0))
## v0.99.11 (2024-08-29)
### Fix
* fix(resume_button): resume button added ([`8be8295`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8be8295b2b38f36da210ab36c5da6d0a00e330cc))
### Refactor
* refactor(icons): general app icon changed; jupyter app icon changed to material icon ([`5d73fe4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d73fe455a568ad40a9fadc5ce6e249d782ad20d))
* refactor: add option to select scan and hide arg bundle buttons ([`7dadab1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7dadab1f14aa41876ad39e8cdc7f7732248cc643))
## v0.99.10 (2024-08-29)
### Fix
* fix(stop_button): queue logic scan changed to halt instead of abort and reset ([`4a89028`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a890281f7eaef02d0ec9f4c5bf080be11fe0fe3))
### Refactor
* refactor(stop_button): stop button changed to QWidget and adapted for toolbar ([`097946f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/097946fd688b8faf770e7cc0e689ea668206bc7a))
* refactor: added hide option for device selection button ([`cdd1752`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cdd175207e922904b2efbb2d9ecf7c556c617f2e))
## v0.99.9 (2024-08-28)
### Fix
* fix: fixed build process and excluded docs and tests from tarballs and wheels ([`719254c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/719254cf0a48e1fc4bd541edba239570778bcfea))
## v0.99.8 (2024-08-28)
### Fix
* fix(website): fixed designer integration for website widget ([`5f37e86`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f37e862c95ac7173b6918ad39bcaef938dad698))
### Refactor
* refactor(website): changed inheritance of website widget to simple qwidget; closes #325 ([`9925bbd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9925bbdb48b55eacbbce9fd6a1555a21b84221f9))
## v0.99.7 (2024-08-28)
### Fix
* fix(toolbar): material icons can accept color as kwarg ([`ffc871e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ffc871ebbd3b68abc3e151bb8f5849e6c50e775e))
## v0.99.6 (2024-08-28)
### Documentation
* docs: various bugs fixed ([`c31e9a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c31e9a3aff3ee8e984674dee0965ee7f1b6e2b8f))
### Fix
* fix(toolbar): use of native qt separators ([`09c6c93`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/09c6c93c397ce4a21c293f6c79106c74b2db65ca))
## v0.99.5 (2024-08-28)
### Documentation
* docs(index): index page is centered ([`02239de`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02239de0a36fcd6cbf97990b0dec1ddf7ecf6ba6))
### Fix
* fix(dock_area): dark button added ([`e6f204b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6f204b6aa295747a68769f43af2e549149b401a))
## v0.99.4 (2024-08-28)
### Documentation
* docs(buttons): added missing buttons docs ([`4e5520a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e5520aee2115d2fc0cebb3865433478a5ec8253))
* docs(developer): tutorial for BECWidget base class ([`ac2cb51`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac2cb5197deef4d51e26ee5beb070eba3ffc210d))
### Fix
* fix(theme): apply theme to all pyqtgraph widgets on manual updates ([`c550186`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c5501860e8e07a53f4bce144d44ed39eda6290ef))
### Refactor
* refactor(buttons): changed grid and thumbnail fig in gallery ([`4591ba8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4591ba8f73e22aba7258cad93c073f1387cb74a0))
* refactor(icons): removed toolbar icons from assets ([`f335763`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f335763280adb1d83ba31f073ce206e4cb5d15ef))
* refactor(icons): moved widget icons to class attribute ICON_NAME ([`e890091`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e890091d862e42317c7a54fc414ba37c85f268b0))
## v0.99.3 (2024-08-27)
### Build
* build: updated min version of bec qthemes ([`d482434`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d48243483ef8228cc5eb85e40a6b8f5da3b45520))
### Fix
* fix(cmaps): unified all defaults to magma cmap ([`1ca9499`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1ca9499edd334c19fe1e7aac71d3940a80a1ec95))
* fix(color maps): color maps should take the background color into account; fixed min colors to 10 ([`060935f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/060935ffc5472a958c337bf60834c5291f104ece))
## v0.99.2 (2024-08-27)
### Ci
* ci: additional tests are not allowed to fail ([`bb385f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bb385f07ca18904461a541b5cadde05398c84438))
### Fix
* 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" ([`cf28730`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf28730515e3c2d5914e0205768734c578711e5c))
## v0.99.1 (2024-08-27)
### Fix
* fix(crosshair): emit all crosshair events, not just line coordinates ([`2265458`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2265458dcc57970db18c62619f5877d542d72e81))
## v0.99.0 (2024-08-25)
### Documentation
* docs(darkmodebutton): added dark mode button docs ([`406c263`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/406c263746f0e809c1a4d98356c48f40428c23d7))
## v0.77.0 (2024-07-02)
### Feature
* feat(darkmodebutton): added button to toggle between dark and light mode ([`cc8c166`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cc8c166b5c1d37e0f64c83801b2347a54a6550b6))
* feat(bec_connector): export config to yaml ([`a391f30`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a391f3018c50fee6a4a06884491b957df80c3cd3))
* feat(utils): colors added convertor for rgba to hex ([`572f2fb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/572f2fb8110d5cb0e80f3ca45ce57ef405572456))
### Fix
* fix(toggle): emit state change ([`c4f3308`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c4f3308dc0c3e4b2064760ccd7372d71b3e49f96))
* fix(waveform): scatter 2D brush error ([`215d59c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/215d59c8bfe7fda9aff8cec8353bef9e1ce2eca1))
* fix(figure): API cleanup ([`008a33a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/008a33a9b192473cc58e90cd6d98c5bcb5f7b8c0))
* fix(figure): if/else logic corrected in subplot_factory ([`3e78723`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3e787234c7274b0698423d7bf9a4c54ec46bad5f))
* fix(image): processing of already displayed data; closes #106 ([`1173510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1173510105d2d70d7e498c2ac1e122cea3a16597))
* fix(bec_figure): full reconstruction with config from other bec figure ([`b6e1e20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b6e1e20b7c8549bb092e981062329e601411dda6))
* fix(motor_map): API changes updates current visualisation; motor_map can be initialised from config ([`2e2d422`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2e2d422910685a2527a3d961a468c787f771ca44))
* fix(image): image add_custom_image fixed, closes #225 ([`f0556e4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0556e44113ffee66cf735aa2dd758c62cb634f4))
* fix(figure): subplot methods consolidated; added subplot factory ([`4a97105`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a97105e4bd2ce77d72dfe5f8307dd9ee65b21b0))
* fix(image): image can be fully reconstructed from config ([`797f73c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/797f73c39aa73e07d6311f3de4baea53f6c380e0))
* fix(image_item): vrange added int for pydantic model check ([`b8f796f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8f796fd3fcc15641e8fc6a3ca75c344ce90fc45))
* fix(bec_figure): waveforms can be initialised from the config; widgets are deleteLater after removal ([`78673ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/78673ea11a47aad878128197ae6213925228ed59))
### Unknown
* Resolve "add VT100 console executing BEC as a widget" ([`c6a14c0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6a14c0768a90695567a83a7895247ed0c64f3ce))
## v0.76.1 (2024-06-29)
### Fix
* fix(plugins): fixes and tests for auto-gen plugins ([`c42511d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c42511dd44cc13577e108a6cef3166376e594f54))
## v0.76.0 (2024-06-28)
### Feature
* feat(designer): added support for creating designer plugins automatically ([`c1dd0ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1dd0ee1906dba1f2e2ae9ce40a84d55c26a1cce))
### Fix
* fix: fixed qwidget inheritance for ring progress bar ([`0610d2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0610d2f9f027f8659e7149f2dfbb316ff30e337d))
### Unknown
* fix:parent set as first kwarg TextBox and WebsiteWidget ([`a45c407`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a45c4075684b93bfdcee03e5a416b84f61d3bc6f))
## v0.75.0 (2024-06-26)
### Feature
* feat(widgets): added simple bec queue widget ([`3faee98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3faee98ec80041a27e4c1f1156178de6f9dcdc63))
### Refactor
* refactor(darkmodebutton): renamed set_dark_mode_enabled to toggle_dark_mode ([`c70724a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c70724a456900bcb06b040407a2c5d497e49ce77))
* refactor(dispatcher): cleanup ([`ca02132`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca02132c8d18535b37e9192e00459d2aca6ba5cf))
## v0.74.1 (2024-06-26)
### Build
* build: added missing pytest-bec-e2e dependency; closes #219 ([`56fdae4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56fdae42757bdb9fa301c1e425a77e98b6eaf92b))
* build: fixed dependency ranges; closes #135 ([`e6a06c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6a06c9f43e0ad6bbfcfa550a2f580d2a27aff66))
### Chore
* chore: sorted dependencies alphabetically ([`21c807f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/21c807f35831fdd1ef2e488ab90edae4719f0cb7))
### Documentation
* docs: fixed doc string ([`f979a63`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f979a63d3d1a008f80e500510909750878ff4303))
### Fix
* fix(rings): rings properties updated right after setting ([`c8b7367`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c8b7367815b095f8e4aa8b819481efb701f2e542))
* fix(motor_map): motor map can be removed from BECFigure with .remove() ([`6b25abf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b25abff70280271e2eeb70450553c05d4b7c99c))
### Test
* test(dark_mode_button): added tests for dark mode button ([`df35aab`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df35aabff30c5d00b1c441132bd370446653741e))
* test(bec_figure): tests for removing widgets with rpc e2e ([`a268caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a268caaa30711fcc7ece542d24578d74cbf65c77))
## v0.74.0 (2024-06-25)
### Documentation
* docs(becfigure): docs added ([`a51b15d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a51b15da3f5e83e0c897a0342bdb05b9c677a179))
### Feature
* feat(waveform1d): dap LMFit model can be added to plot ([`1866ba6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1866ba66c8e3526661beb13fff3e13af6a0ae562))
### Test
* test(waveform1d): dap e2e test added ([`7271b42`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7271b422f98ef9264970d708811c414b69a644db))
## v0.73.2 (2024-06-25)
### Fix
* fix(vscode): only run terminate if the process is still alive ([`7120f3e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7120f3e93b054b788f15e2d5bcd688e3c140c1ce))
* fix(rpc): trigger shutdown of server when gui is terminated ([`acc1318`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/acc13183e28030e3ca9af21bb081e1eed081622b))
* fix(rpc): remove of calling "close" and waiting for gui_is_alive ([`f75fc19`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f75fc19c5b10022763252917ca473f404a25165a))
## v0.73.1 (2024-06-25)
### Fix
* fix(ringprogressbar): removed hard-coded endpoint strings ([`1de3cbf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1de3cbf65a1832150917a7549a1bf3efdee6371a))
## v0.73.0 (2024-06-25)
### Feature
* feat: add new default scaling of image_item ([`df812ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df812eaad5989f2930dde41d87491868505af946))
### Test
* test: add test for imageitem ([`88ecd05`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/88ecd05b95974938ef1efff40e81854baf004cb4))
## v0.72.2 (2024-06-25)
### Fix
* fix(designer): fixed designer for pyenv and venv; closes #237 ([`e631fc1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e631fc15d8707b73d58cb64316e115a7e43961ea))
## v0.72.1 (2024-06-24)
### Fix
* fix: renamed spiral progress bar to ring progress bar; closes #235 ([`e5c0087`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5c0087c9aed831edbe1c172746325a772a3bafa))
### Test
* test: bugfix to prohibit leackage of mock ([`4348ed1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4348ed1bb2182da6bdecaf372d6db85279e60af8))
## v0.72.0 (2024-06-24)
### Feature
* feat(connector): added threadpool wrapper ([`4ca1efe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4ca1efeeb8955604069f7b98374c7f82e1a8da67))

View File

@@ -17,7 +17,7 @@ cd bec_widgets
pip install -e .[dev,pyqt6]
```
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
BEC Widgets currently supports both PyQt5 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
Python Qt distributions manually.
To select a specific Python Qt distribution, install the package with an additional tag:
@@ -28,7 +28,7 @@ pip install bec_widgets[pyqt6]
or
```bash
pip install bec_widgets[pyside6]
pip install bec_widgets[pyqt5]
```
## Documentation

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import threading
from queue import Queue
from typing import TYPE_CHECKING
from pydantic import BaseModel
@@ -27,17 +25,6 @@ class AutoUpdates:
def __init__(self, gui: BECDockArea):
self.gui = gui
self.msg_queue = Queue()
self.auto_update_thread = None
self._shutdown_sentinel = object()
self.start()
def start(self):
"""
Start the auto update thread.
"""
self.auto_update_thread = threading.Thread(target=self.process_queue)
self.auto_update_thread.start()
def start_default_dock(self):
"""
@@ -92,16 +79,6 @@ class AutoUpdates:
info = self.get_scan_info(msg)
self.handler(info)
def process_queue(self):
"""
Process the message queue.
"""
while True:
msg = self.msg_queue.get()
if msg is self._shutdown_sentinel:
break
self.run(msg)
@staticmethod
def get_selected_device(monitored_devices, selected_device):
"""
@@ -174,11 +151,3 @@ class AutoUpdates:
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)
def shutdown(self):
"""
Shutdown the auto update thread.
"""
self.msg_queue.put(self._shutdown_sentinel)
if self.auto_update_thread:
self.auto_update_thread.join()

View File

@@ -13,49 +13,19 @@ class Widgets(str, enum.Enum):
Enum for the available widgets.
"""
AbortButton = "AbortButton"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
BECImageWidget = "BECImageWidget"
BECMotorMapWidget = "BECMotorMapWidget"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
BECWaveformWidget = "BECWaveformWidget"
DarkModeButton = "DarkModeButton"
DeviceBrowser = "DeviceBrowser"
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
ResetButton = "ResetButton"
ResumeButton = "ResumeButton"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
StopButton = "StopButton"
TextBox = "TextBox"
VSCodeEditor = "VSCodeEditor"
WebsiteWidget = "WebsiteWidget"
class AbortButton(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class BECCurve(RPCBase):
@rpc_call
def remove(self):
@@ -210,7 +180,7 @@ class BECDock(RPCBase):
@property
@rpc_call
def widget_list(self) -> "list[BECWidget]":
def widget_list(self) -> "list[BECConnector]":
"""
Get the widgets in the dock.
@@ -251,13 +221,13 @@ class BECDock(RPCBase):
@rpc_call
def add_widget(
self,
widget: "BECWidget | str",
widget: "BECConnector | str",
row=None,
col=0,
rowspan=1,
colspan=1,
shift: "Literal['down', 'up', 'left', 'right']" = "down",
) -> "BECWidget":
) -> "BECConnector":
"""
Add a widget to the dock.
@@ -375,7 +345,7 @@ class BECDockArea(RPCBase, BECGuiClientMixin):
name: "str" = None,
position: "Literal['bottom', 'top', 'left', 'right', 'above', 'below']" = None,
relative_to: "BECDock | None" = None,
closable: "bool" = True,
closable: "bool" = False,
floating: "bool" = False,
prefix: "str" = "dock",
widget: "str | QWidget | None" = None,
@@ -494,9 +464,8 @@ class BECFigure(RPCBase):
@rpc_call
def plot(
self,
arg1: "list | np.ndarray | str | None" = None,
y: "list | np.ndarray | None" = None,
x: "list | np.ndarray | None" = None,
y: "list | np.ndarray | None" = None,
x_name: "str | None" = None,
y_name: "str | None" = None,
z_name: "str | None" = None,
@@ -504,7 +473,7 @@ class BECFigure(RPCBase):
y_entry: "str | None" = None,
z_entry: "str | None" = None,
color: "str | None" = None,
color_map_z: "str | None" = "magma",
color_map_z: "str | None" = "plasma",
label: "str | None" = None,
validate: "bool" = True,
new: "bool" = False,
@@ -518,9 +487,8 @@ class BECFigure(RPCBase):
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
Args:
arg1(list | np.ndarray | str | None): First argument which can be x data, y data, or y_name.
y(list | np.ndarray): Custom y data to plot.
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
@@ -640,12 +608,6 @@ class BECFigure(RPCBase):
theme(Literal["dark","light"]): The theme to set for the figure widget.
"""
@rpc_call
def export(self):
"""
Export the plot widget.
"""
@rpc_call
def clear_all(self):
"""
@@ -847,7 +809,7 @@ class BECImageShow(RPCBase):
"""
@rpc_call
def image(
def add_monitor_image(
self,
monitor: "str",
color_map: "Optional[str]" = "magma",
@@ -858,17 +820,7 @@ class BECImageShow(RPCBase):
**kwargs,
) -> "BECImageItem":
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
Returns:
BECImageItem: The image item.
None
"""
@rpc_call
@@ -1138,12 +1090,6 @@ class BECImageShow(RPCBase):
lock(bool): True to lock, False to unlock.
"""
@rpc_call
def export(self):
"""
Show the Export Dialog of the plot widget.
"""
@rpc_call
def remove(self):
"""
@@ -1160,180 +1106,6 @@ class BECImageShow(RPCBase):
"""
class BECImageWidget(RPCBase):
@rpc_call
def image(
self,
monitor: "str",
color_map: "Optional[str]" = "magma",
color_bar: "Optional[Literal['simple', 'full']]" = "full",
downsample: "Optional[bool]" = True,
opacity: "Optional[float]" = 1.0,
vrange: "Optional[tuple[int, int]]" = None,
**kwargs,
) -> "BECImageItem":
"""
None
"""
@rpc_call
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str"):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot.
"""
@rpc_call
def set_x_label(self, x_label: "str"):
"""
Set the x-axis label of the plot widget.
Args:
x_label(str): Label of the x-axis.
"""
@rpc_call
def set_y_label(self, y_label: "str"):
"""
Set the y-axis label of the plot widget.
Args:
y_label(str): Label of the y-axis.
"""
@rpc_call
def set_x_scale(self, x_scale: "Literal['linear', 'log']"):
"""
Set the scale of the x-axis of the plot widget.
Args:
x_scale(Literal["linear", "log"]): Scale of the x-axis.
"""
@rpc_call
def set_y_scale(self, y_scale: "Literal['linear', 'log']"):
"""
Set the scale of the y-axis of the plot widget.
Args:
y_scale(Literal["linear", "log"]): Scale of the y-axis.
"""
@rpc_call
def set_x_lim(self, x_lim: "tuple"):
"""
Set the limits of the x-axis of the plot widget.
Args:
x_lim(tuple): Limits of the x-axis.
"""
@rpc_call
def set_y_lim(self, y_lim: "tuple"):
"""
Set the limits of the y-axis of the plot widget.
Args:
y_lim(tuple): Limits of the y-axis.
"""
@rpc_call
def set_vrange(self, vmin: "float", vmax: "float", name: "str" = None):
"""
Set the range of the color bar.
If name is not specified, then set vrange for all images.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
name(str): The name of the image. If None, apply to all images.
"""
@rpc_call
def set_fft(self, enable: "bool" = False, name: "str" = None):
"""
Set the FFT of the image.
If name is not specified, then set FFT for all images.
Args:
enable(bool): Whether to perform FFT on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
@rpc_call
def set_transpose(self, enable: "bool" = False, name: "str" = None):
"""
Set the transpose of the image.
If name is not specified, then set transpose for all images.
Args:
enable(bool): Whether to transpose the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
@rpc_call
def set_rotation(self, deg_90: "int" = 0, name: "str" = None):
"""
Set the rotation of the image.
If name is not specified, then set rotation for all images.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
@rpc_call
def set_log(self, enable: "bool" = False, name: "str" = None):
"""
Set the log of the image.
If name is not specified, then set log for all images.
Args:
enable(bool): Whether to perform log on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
@rpc_call
def set_grid(self, x_grid: "bool", y_grid: "bool"):
"""
Set the grid visibility of the plot widget.
Args:
x_grid(bool): Visibility of the x-axis grid.
y_grid(bool): Visibility of the y-axis grid.
"""
@rpc_call
def lock_aspect_ratio(self, lock: "bool"):
"""
Lock the aspect ratio of the plot widget.
Args:
lock(bool): Lock the aspect ratio.
"""
class BECMotorMap(RPCBase):
@property
@rpc_call
@@ -1426,12 +1198,6 @@ class BECMotorMap(RPCBase):
dict: Data of the motor map.
"""
@rpc_call
def export(self):
"""
Show the Export Dialog of the plot widget.
"""
@rpc_call
def remove(self):
"""
@@ -1526,12 +1292,6 @@ class BECMotorMapWidget(RPCBase):
Reset the history of the motor map.
"""
@rpc_call
def export(self):
"""
Show the export dialog for the motor map.
"""
class BECPlotBase(RPCBase):
@property
@@ -1660,12 +1420,6 @@ class BECPlotBase(RPCBase):
lock(bool): True to lock, False to unlock.
"""
@rpc_call
def export(self):
"""
Show the Export Dialog of the plot widget.
"""
@rpc_call
def remove(self):
"""
@@ -1739,9 +1493,8 @@ class BECWaveform(RPCBase):
@rpc_call
def plot(
self,
arg1: "list | np.ndarray | str | None" = None,
y: "list | np.ndarray | None" = None,
x: "list | np.ndarray | None" = None,
y: "list | np.ndarray | None" = None,
x_name: "str | None" = None,
y_name: "str | None" = None,
z_name: "str | None" = None,
@@ -1749,24 +1502,17 @@ class BECWaveform(RPCBase):
y_entry: "str | None" = None,
z_entry: "str | None" = None,
color: "str | None" = None,
color_map_z: "str | None" = "magma",
color_map_z: "str | None" = "plasma",
label: "str | None" = None,
validate: "bool" = True,
dap: "str | None" = None,
**kwargs,
) -> "BECCurve":
"""
Plot a curve to the plot widget.
Args:
arg1(list | np.ndarray | str | None): First argument which can be x data, y data, or y_name.
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
x(list | np.ndarray): Custom y data to plot.
x_name(str): Name of the x signal.
- "best_effort": Use the best effort signal.
- "timestamp": Use the timestamp signal.
- "index": Use the index signal.
- Custom signal name of device from BEC.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
@@ -1776,7 +1522,7 @@ class BECWaveform(RPCBase):
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
dap(str): The dap model to use for the curve, only available for sync devices. If not specified, none will be added.
dap(str): The dap model to use for the curve. If not specified, none will be added.
Returns:
BECCurve: The curve object.
@@ -1785,13 +1531,12 @@ class BECWaveform(RPCBase):
@rpc_call
def add_dap(
self,
x_name: "str | None" = None,
y_name: "str | None" = None,
x_name: "str",
y_name: "str",
x_entry: "Optional[str]" = None,
y_entry: "Optional[str]" = None,
color: "Optional[str]" = None,
dap: "str" = "GaussianModel",
validate_bec: "bool" = True,
**kwargs,
) -> "BECCurve":
"""
@@ -1803,28 +1548,15 @@ class BECWaveform(RPCBase):
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
color_map_z(str): The color map to use for the z-axis.
label(str, optional): Label of the curve. Defaults to None.
dap(str): The dap model to use for the curve.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
@rpc_call
def set_x(self, x_name: "str", x_entry: "str | None" = None):
"""
Change the x axis of the plot widget.
Args:
x_name(str): Name of the x signal.
- "best_effort": Use the best effort signal.
- "timestamp": Use the timestamp signal.
- "index": Use the index signal.
- Custom signal name of device from BEC.
x_entry(str): Entry of the x signal.
"""
@rpc_call
def get_dap_params(self) -> "dict":
"""
@@ -1994,15 +1726,6 @@ class BECWaveform(RPCBase):
y(bool): Show grid on the y-axis.
"""
@rpc_call
def set_colormap(self, colormap: "str | None" = None):
"""
Set the colormap of the plot widget.
Args:
colormap(str, optional): Scale the colors of curves to colormap. If None, use the default color palette.
"""
@rpc_call
def lock_aspect_ratio(self, lock):
"""
@@ -2012,24 +1735,12 @@ class BECWaveform(RPCBase):
lock(bool): True to lock, False to unlock.
"""
@rpc_call
def export(self):
"""
Show the Export Dialog of the plot widget.
"""
@rpc_call
def remove(self):
"""
Remove the plot widget from the figure.
"""
@rpc_call
def clear_all(self):
"""
None
"""
@rpc_call
def set_legend_label_size(self, size: "int" = None):
"""
@@ -2040,304 +1751,6 @@ class BECWaveform(RPCBase):
"""
class BECWaveformWidget(RPCBase):
@property
@rpc_call
def curves(self) -> "list[BECCurve]":
"""
Get the curves of the plot widget as a list
Returns:
list: List of curves.
"""
@rpc_call
def plot(
self,
arg1: "list | np.ndarray | str | None" = None,
x: "list | np.ndarray | None" = None,
y: "list | np.ndarray | None" = None,
x_name: "str | None" = None,
y_name: "str | None" = None,
z_name: "str | None" = None,
x_entry: "str | None" = None,
y_entry: "str | None" = None,
z_entry: "str | None" = None,
color: "str | None" = None,
color_map_z: "str | None" = "magma",
label: "str | None" = None,
validate: "bool" = True,
dap: "str | None" = None,
**kwargs,
) -> "BECCurve":
"""
Plot a curve to the plot widget.
Args:
arg1(list | np.ndarray | str | None): First argument which can be x data(list | np.ndarray), y data(list | np.ndarray), or y_name(str).
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
color(str): The color of the curve.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
dap(str): The dap model to use for the curve. If not specified, none will be added.
Returns:
BECCurve: The curve object.
"""
@rpc_call
def add_dap(
self,
x_name: "str",
y_name: "str",
x_entry: "str | None" = None,
y_entry: "str | None" = None,
color: "str | None" = None,
dap: "str" = "GaussianModel",
validate_bec: "bool" = True,
**kwargs,
) -> "BECCurve":
"""
Add LMFIT dap model curve to the plot widget.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
dap(str): The dap model to use for the curve.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
@rpc_call
def get_dap_params(self) -> "dict":
"""
Get the DAP parameters of all DAP curves.
Returns:
dict: DAP parameters of all DAP curves.
"""
@rpc_call
def remove_curve(self, *identifiers):
"""
Remove a curve from the plot widget.
Args:
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
"""
@rpc_call
def scan_history(self, scan_index: "int" = None, scan_id: "str" = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
@rpc_call
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict | pd.DataFrame":
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
@rpc_call
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_x(self, x_name: "str", x_entry: "str | None" = None):
"""
Change the x axis of the plot widget.
Args:
x_name(str): Name of the x signal.
- "best_effort": Use the best effort signal.
- "timestamp": Use the timestamp signal.
- "index": Use the index signal.
- Custom signal name of device from BEC.
x_entry(str): Entry of the x signal.
"""
@rpc_call
def set_title(self, title: "str"):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot.
"""
@rpc_call
def set_x_label(self, x_label: "str"):
"""
Set the x-axis label of the plot widget.
Args:
x_label(str): Label of the x-axis.
"""
@rpc_call
def set_y_label(self, y_label: "str"):
"""
Set the y-axis label of the plot widget.
Args:
y_label(str): Label of the y-axis.
"""
@rpc_call
def set_x_scale(self, x_scale: "Literal['linear', 'log']"):
"""
Set the scale of the x-axis of the plot widget.
Args:
x_scale(Literal["linear", "log"]): Scale of the x-axis.
"""
@rpc_call
def set_y_scale(self, y_scale: "Literal['linear', 'log']"):
"""
Set the scale of the y-axis of the plot widget.
Args:
y_scale(Literal["linear", "log"]): Scale of the y-axis.
"""
@rpc_call
def set_x_lim(self, x_lim: "tuple"):
"""
Set the limits of the x-axis of the plot widget.
Args:
x_lim(tuple): Limits of the x-axis.
"""
@rpc_call
def set_y_lim(self, y_lim: "tuple"):
"""
Set the limits of the y-axis of the plot widget.
Args:
y_lim(tuple): Limits of the y-axis.
"""
@rpc_call
def set_legend_label_size(self, legend_label_size: "int"):
"""
Set the size of the legend labels of the plot widget.
Args:
legend_label_size(int): Size of the legend labels.
"""
@rpc_call
def set_auto_range(self, enabled: "bool", axis: "str" = "xy"):
"""
Set the auto range of the plot widget.
Args:
enabled(bool): If True, enable the auto range.
axis(str, optional): The axis to enable the auto range.
- "xy": Enable auto range for both x and y axis.
- "x": Enable auto range for x axis.
- "y": Enable auto range for y axis.
"""
@rpc_call
def set_grid(self, x_grid: "bool", y_grid: "bool"):
"""
Set the grid visibility of the plot widget.
Args:
x_grid(bool): Visibility of the x-axis grid.
y_grid(bool): Visibility of the y-axis grid.
"""
@rpc_call
def lock_aspect_ratio(self, lock: "bool"):
"""
Lock the aspect ratio of the plot widget.
Args:
lock(bool): Lock the aspect ratio.
"""
@rpc_call
def export(self):
"""
Show the export dialog for the plot widget.
"""
@rpc_call
def export_to_matplotlib(self):
"""
Export the plot widget to Matplotlib.
"""
class DarkModeButton(RPCBase):
@rpc_call
def toggle_dark_mode(self) -> None:
"""
Toggle the dark mode state. This will change the theme of the entire
application to dark or light mode.
"""
class DeviceBrowser(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class DeviceComboBox(RPCBase):
@property
@rpc_call
@@ -2392,64 +1805,6 @@ class DeviceLineEdit(RPCBase):
"""
class PositionerBox(RPCBase):
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerControlLine(RPCBase):
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class ResetButton(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class ResumeButton(RPCBase):
@property
@rpc_call
def _config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class Ring(RPCBase):
@rpc_call
def _get_all_rpc(self) -> "dict":

View File

@@ -2,20 +2,20 @@ from __future__ import annotations
import importlib
import importlib.metadata as imd
import json
import os
import select
import subprocess
import sys
import threading
import time
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.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
@@ -23,6 +23,10 @@ from bec_widgets.cli.auto_updates import AutoUpdates
if TYPE_CHECKING:
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",))
@@ -83,7 +87,7 @@ def _get_output(process, logger) -> None:
print(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
def _start_plot_process(gui_id, gui_class, config, logger=None) -> None:
"""
Start the plot in a new process.
@@ -94,13 +98,10 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
# pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
if config:
if isinstance(config, dict):
config = json.dumps(config)
command.extend(["--config", config])
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
@@ -180,7 +181,7 @@ class BECGuiClientMixin:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
self.auto_updates.msg_queue.put(msg)
self.auto_updates.run(msg)
def show(self) -> None:
"""
@@ -189,7 +190,7 @@ class BECGuiClientMixin:
if self._process is None or self._process.poll() is not None:
self._start_update_script()
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_path
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
@@ -209,8 +210,6 @@ class BECGuiClientMixin:
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
if self.auto_updates is not None:
self.auto_updates.shutdown()
class RPCResponseTimeoutError(Exception):
@@ -222,14 +221,54 @@ class RPCResponseTimeoutError(Exception):
)
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 = BECClient() # BECClient is a singleton; here, we simply get the instance
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
self._msg_wait_event = threading.Event()
self._rpc_response = None
super().__init__()
# print(f"RPCBase: {self._gui_id}")
@@ -273,38 +312,23 @@ class RPCBase:
# 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,
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:
try:
finished = self._msg_wait_event.wait(10)
if not finished:
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
response = redis_msg.wait(timeout)
@staticmethod
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
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:
@@ -332,8 +356,4 @@ class RPCBase:
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
return heart is not None

View File

@@ -5,12 +5,13 @@ import argparse
import inspect
import os
import sys
from typing import Literal
import black
import isort
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 get_rpc_classes
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -39,20 +40,17 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
self.content = ""
def generate_client(self, class_container: BECClassContainer):
def generate_client(
self, published_classes: dict[Literal["connector_classes", "top_level_classes"], list[type]]
):
"""
Generate the client for the published classes.
Args:
class_container: The class container with the classes to generate the client for.
published_classes(dict): A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
rpc_top_level_classes = class_container.rpc_top_level_classes
rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = class_container.connector_classes
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
self.write_client_enum(published_classes["top_level_classes"])
for cls in published_classes["connector_classes"]:
self.content += "\n\n"
self.generate_content_for_class(cls)
@@ -158,12 +156,13 @@ def main():
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets")
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
for cls in rpc_classes.plugins:
for cls in rpc_classes["top_level_classes"]:
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue

View File

@@ -29,7 +29,7 @@ class RPCWidgetHandler:
from bec_widgets.utils.plugin_utils import get_rpc_classes
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
self._widget_classes = {cls.__name__: cls for cls in clss["top_level_classes"]}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import inspect
import json
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
@@ -28,13 +27,12 @@ class BECWidgetsCLIServer:
def __init__(
self,
gui_id: str,
gui_id: str = None,
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()
@@ -48,12 +46,11 @@ 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
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
try:
@@ -113,16 +110,16 @@ class BECWidgetsCLIServer:
return obj
def emit_heartbeat(self):
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
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,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._shutdown_event = True
self._heartbeat_timer.stop()
self.emit_heartbeat()
self.gui.close()
self.client.shutdown()
@@ -130,44 +127,24 @@ class BECWidgetsCLIServer:
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
def write(self, buffer):
self._buffer.append(buffer)
for line in buffer.rstrip().splitlines():
line = line.rstrip()
if line:
self._log_func(line)
def flush(self):
lines, _, remaining = "".join(self._buffer).rpartition("\n")
self._log_func(lines)
self._buffer = [remaining]
return
def close(self):
return
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
if config:
try:
config = json.loads(config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# 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,
)
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
return server
def main():
import argparse
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
@@ -182,7 +159,7 @@ def main():
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("--config", type=str, help="Config file")
args = parser.parse_args()
@@ -204,15 +181,21 @@ def main():
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
server = _start_server(args.id, gui_class, args.config)
service_config = ServiceConfig(args.config)
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
server = BECWidgetsCLIServer(gui_id=args.id, config=service_config, gui_class=gui_class)
gui = server.gui
win.setCentralWidget(gui)
@@ -233,5 +216,4 @@ def main():
if __name__ == "__main__": # pragma: no cover
sys.argv = ["bec_widgets.cli.server", "--id", "test", "--gui_class", "BECDockArea"]
main()

View File

@@ -0,0 +1,9 @@
from .motor_movement import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -1,92 +0,0 @@
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.ui_loader import UILoader
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECGeneralApp(QMainWindow):
def __init__(self, parent=None):
super(BECGeneralApp, self).__init__(parent)
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
self.load_ui(ui_file_path)
self.resize(1280, 720)
self.ini_ui()
def ini_ui(self):
self._setup_icons()
self._hook_menubar_docs()
self._hook_theme_bar()
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def _hook_menubar_docs(self):
# BEC Docs
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
# BEC Widgets Docs
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
# Bug report
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
def change_theme(self, theme):
apply_theme(theme)
def _setup_icons(self):
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
computer_icon = QIcon.fromTheme("computer")
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
self.ui.action_BEC_docs.setIcon(help_icon)
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
self.ui.action_bug_report.setIcon(bug_icon)
self.ui.central_tab.setTabIcon(0, widget_icon)
self.ui.central_tab.setTabIcon(1, computer_icon)
def _hook_theme_bar(self):
self.ui.action_light.setCheckable(True)
self.ui.action_dark.setCheckable(True)
# Create an action group to make sure only one can be checked at a time
theme_group = QActionGroup(self)
theme_group.addAction(self.ui.action_light)
theme_group.addAction(self.ui.action_dark)
theme_group.setExclusive(True)
# Connect the actions to the theme change method
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
self.ui.action_dark.trigger()
def main(): # pragma: no cover
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
main_window = BECGeneralApp()
main_window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,262 +0,0 @@
<?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>1718</width>
<height>1139</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTabWidget" name="central_tab">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="dock_area_tab">
<attribute name="title">
<string>Dock Area</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="vscode_tab">
<attribute name="icon">
<iconset theme="QIcon::ThemeIcon::Computer"/>
</attribute>
<attribute name="title">
<string>Visual Studio Code</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="VSCodeEditor" name="vscode"/>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>31</height>
</rect>
</property>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="action_BEC_docs"/>
<addaction name="action_BEC_widgets_docs"/>
<addaction name="action_bug_report"/>
</widget>
<widget class="QMenu" name="menuTheme">
<property name="title">
<string>Theme</string>
</property>
<addaction name="action_light"/>
<addaction name="action_dark"/>
</widget>
<addaction name="menuTheme"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="dock_scan_control">
<property name="windowTitle">
<string>Scan Control</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="ScanControl" name="scan_control"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_status_2">
<property name="windowTitle">
<string>BEC Service Status</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_3">
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECStatusBox" name="bec_status_box_2"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_queue">
<property name="windowTitle">
<string>Scan Queue</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_4">
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECQueue" name="bec_queue">
<row/>
<column/>
<column/>
<column/>
<item row="0" column="0"/>
<item row="0" column="1"/>
<item row="0" column="2"/>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="action_BEC_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Docs</string>
</property>
</action>
<action name="action_BEC_widgets_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Widgets Docs</string>
</property>
</action>
<action name="action_bug_report">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogError"/>
</property>
<property name="text">
<string>Bug Report</string>
</property>
</action>
<action name="action_light">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Light</string>
</property>
</action>
<action name="action_dark">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Dark</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWebEngineView</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QTableWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>VSCodeEditor</class>
<extends>WebsiteWidget</extends>
<header>vs_code_editor</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>QWebEngineView</class>
<extends></extends>
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,15 +0,0 @@
import webbrowser
class BECWebLinksMixin:
@staticmethod
def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")

View File

@@ -2,21 +2,14 @@ import os
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
import qdarktheme
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
QHBoxLayout,
QSplitter,
QTabWidget,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils import BECDispatcher, UILoader
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
@@ -28,8 +21,14 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def __init__(self, parent=None):
super().__init__(parent)
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "jupyter_console_window.ui"), self)
self._init_ui()
self.ui.splitter.setSizes([200, 100])
self.safe_close = False
# console push
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(
@@ -41,49 +40,27 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"w1_c": self.w1_c,
"w2_c": self.w2_c,
"w3_c": self.w3_c,
"w4": self.w4,
"w5": self.w5,
"w6": self.w6,
"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,
"plt": self.plt,
"bar": self.bar,
}
)
def _init_ui(self):
self.layout = QHBoxLayout(self)
# Plotting window
self.glw_1_layout = QVBoxLayout(self.ui.glw) # Create a new QVBoxLayout
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
# Horizontal splitter
splitter = QSplitter(self)
self.layout.addWidget(splitter)
tab_widget = QTabWidget(splitter)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
second_tab = QWidget()
second_tab_layout = QVBoxLayout(second_tab)
self.figure = BECFigure(parent=self, gui_id="figure")
second_tab_layout.addWidget(self.figure)
tab_widget.addTab(second_tab, "BEC Figure")
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
self.dock = BECDockArea(gui_id="remote")
self.dock_layout.addWidget(self.dock)
# add stuff to figure
self._init_figure()
@@ -91,102 +68,72 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# init dock for testing
self._init_dock()
self.setWindowTitle("Jupyter Console Window")
self.console_layout = QVBoxLayout(self.ui.widget_console)
self.console = BECJupyterConsole(inprocess=True)
self.console_layout.addWidget(self.console)
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.set(
title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position",
y_label="Intensity (A.U.)",
)
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
self.w3 = self.figure.image(
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
)
self.w4 = self.figure.plot(
x_name="samx",
y_name="samy",
z_name="bpm4i",
color_map_z="magma",
new=True,
title="2D scatter plot - w4",
row=0,
col=3,
)
self.w5 = self.figure.plot(
y_name="bpm4i",
new=True,
title="Best Effort Plot - w5",
dap="GaussianModel",
row=1,
col=0,
)
self.w6 = self.figure.plot(
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
)
self.w7 = self.figure.plot(
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
)
self.w8 = self.figure.plot(
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
)
self.w9 = self.figure.plot(
x_name="timestamp",
y_name="monitor_async",
new=True,
title="Async Plot - timestamp - w9",
row=2,
col=1,
)
self.w10 = self.figure.plot(
x_name="index",
y_name="monitor_async",
new=True,
title="Async Plot - index - w10",
row=2,
col=2,
self.figure.plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="cividis")
self.figure.motor_map("samx", "samy")
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
self.figure.plot(
x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma", new=True
)
self.figure.change_layout(2, 2)
self.w1 = self.figure[0, 0]
self.w2 = self.figure[0, 1]
self.w3 = self.figure[1, 0]
self.w4 = self.figure[1, 1]
# Plot Customisation
self.w1.set_title("Waveform 1")
self.w1.set_x_label("Motor Position (samx)")
self.w1.set_y_label("Intensity A.U.")
# Image Customisation
self.w3.set_title("Eiger Image")
self.w3.set_x_label("X")
self.w3.set_y_label("Y")
# Configs to try to pass
self.w1_c = self.w1._config_dict
self.w2_c = self.w2._config_dict
self.w3_c = self.w3._config_dict
# curves for w1
self.c1 = self.w1.get_config()
self.fig_c = self.figure._config_dict
def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0")
self.mm = self.d0.add_widget("BECMotorMapWidget")
self.mm.change_motors("samx", "samy")
self.fig0 = self.d0.add_widget("BECFigure")
data = np.random.rand(10, 2)
self.fig0.plot(data, label="2d Data")
self.fig0.image("eiger", vrange=(0, 100))
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("eiger")
self.fig1 = self.d1.add_widget("BECFigure")
self.fig1.plot(x_name="samx", y_name="bpm4i")
self.fig1.plot(x_name="samx", y_name="bpm3a")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
self.wf.plot(x_name="samx", y_name="bpm3a")
self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
# self.bar.set_diameter(200)
# self.d3 = self.dock.add_dock(name="dock_3", position="bottom")
# self.colormap = pg.GradientWidget()
# self.d3.add_widget(self.colormap, row=0, col=0)
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
self.plt = self.fig2.plot(x_name="samx", y_name="bpm3a")
self.plt.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.dock.save_state()
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock.cleanup()
self.dock.close()
self.figure.cleanup()
self.figure.close()
self.console.close()
self.figure.clear_all()
self.figure.client.shutdown()
super().closeEvent(event)
@@ -200,8 +147,9 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark")
icon = material_icon("terminal", color="#434343", filled=True)
qdarktheme.setup_theme("auto")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>2104</width>
<height>966</height>
</rect>
</property>
<property name="windowTitle">
<string>Plotting Console</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_1">
<attribute name="title">
<string>BECDock</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="dock_placeholder" native="true"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>BECFigure</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QWidget" name="glw" native="true"/>
</item>
</layout>
</widget>
</widget>
<widget class="QWidget" name="widget_console" native="true"/>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,9 @@
from .motor_control_compilations import (
MotorControlApp,
MotorControlMap,
MotorControlPanel,
MotorControlPanelAbsolute,
MotorControlPanelRelative,
MotorCoordinateTable,
MotorThread,
)

View File

@@ -0,0 +1,250 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import qdarktheme
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.motor_control.motor_control import MotorThread
from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable
from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import (
MotorControlAbsolute,
)
from bec_widgets.widgets.motor_control.movement_relative.movement_relative import (
MotorControlRelative,
)
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
CONFIG_DEFAULT = {
"motor_control": {
"motor_x": "samx",
"motor_y": "samy",
"step_size_x": 3,
"step_size_y": 3,
"precision": 4,
"step_x_y_same": False,
"move_with_arrows": False,
},
"plot_settings": {
"colormap": "Greys",
"scatter_size": 5,
"max_points": 1000,
"num_dim_points": 100,
"precision": 2,
"num_columns": 1,
"background_value": 25,
},
"motors": [
{
"plot_name": "Motor Map",
"x_label": "Motor X",
"y_label": "Motor Y",
"signals": {
"x": [{"name": "samx", "entry": "samx"}],
"y": [{"name": "samy", "entry": "samy"}],
},
}
],
}
class MotorControlApp(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
# self.motion_map = MotorMap(client=self.client, config=self.config)
# Create MotorCoordinateTable
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
# splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
splitter.addWidget(self.motor_table)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
# lambda x, y: self.motion_map.change_motors(x, y, 0)
# )
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
self.motor_table.add_coordinate
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_table.set_precision
)
self.motor_control_panel.relative_widget.precision_signal.connect(
self.motor_control_panel.absolute_widget.set_precision
)
# self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
class MotorControlMap(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
# Widgets
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
# Create MotorMap
# self.motion_map = MotorMap(client=self.client, config=self.config)
# Create the splitter and add MotorMap and MotorControlPanel
splitter = QSplitter(Qt.Horizontal)
# splitter.addWidget(self.motion_map)
splitter.addWidget(self.motor_control_panel)
# Set the main layout
layout = QVBoxLayout(self)
layout.addWidget(splitter)
self.setLayout(layout)
# Connecting signals and slots
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
# lambda x, y: self.motion_map.change_motors(x, y, 0)
# )
class MotorControlPanel(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=self.client, config=self.config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
# Set the window to a fixed size based on its contents
# self.layout().setSizeConstraint(layout.SetFixedSize)
class MotorControlPanelAbsolute(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.absolute_widget = MotorControlAbsolute(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.absolute_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
class MotorControlPanelRelative(QWidget):
def __init__(self, parent=None, client=None, config=None):
super().__init__(parent)
bec_dispatcher = BECDispatcher()
self.client = bec_dispatcher.client if client is None else client
self.config = config
self.motor_thread = MotorThread(client=self.client)
self.selection_widget = MotorControlSelection(
client=client, config=config, motor_thread=self.motor_thread
)
self.relative_widget = MotorControlRelative(
client=client, config=config, motor_thread=self.motor_thread
)
layout = QVBoxLayout(self)
layout.addWidget(self.selection_widget)
layout.addWidget(self.relative_widget)
# Connecting signals and slots
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
parser.add_argument(
"-v",
"--variant",
type=str,
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
help="Select the variant of the motor control to run. "
"'app' for the full application, "
"'map' for MotorMap, "
"'panel' for the MotorControlPanel, "
"'panel_abs' for MotorControlPanel with absolute control, "
"'panel_rel' for MotorControlPanel with relative control.",
)
args = parser.parse_args()
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication([])
qdarktheme.setup_theme("auto")
if args.variant == "app":
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "map":
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel":
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_abs":
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
elif args.variant == "panel_rel":
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
else:
print("Please specify a valid variant to run. Use -h for help.")
print("Running the full application by default.")
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,926 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1561</width>
<height>748</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>1409</width>
<height>748</height>
</size>
</property>
<property name="windowTitle">
<string>Motor Controller</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="8,5,8">
<item>
<widget class="GraphicsLayoutWidget" name="glw">
<property name="enabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="Controls">
<property name="minimumSize">
<size>
<width>221</width>
<height>471</height>
</size>
</property>
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
<property name="spacing">
<number>1</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<widget class="QGroupBox" name="motorSelection">
<property name="minimumSize">
<size>
<width>261</width>
<height>145</height>
</size>
</property>
<property name="title">
<string>Motor Selection</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="2" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Motor Y</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="comboBox_motor_x"/>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="comboBox_motor_y"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Motor X</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QPushButton" name="pushButton_connecMotors">
<property name="text">
<string>Connect Motors</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl">
<property name="minimumSize">
<size>
<width>261</width>
<height>339</height>
</size>
</property>
<property name="title">
<string>Motor Relative</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QCheckBox" name="checkBox_enableArrows">
<property name="text">
<string>Move with arrow keys</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkBox_same_xy">
<property name="text">
<string>Step [X] = Step [Y]</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="step_grid">
<item row="2" column="0">
<widget class="QLabel" name="label_step_y">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [Y]</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Decimal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_x">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_step_x">
<property name="minimumSize">
<size>
<width>111</width>
<height>19</height>
</size>
</property>
<property name="text">
<string>Step [X]</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="spinBox_step_y">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>99.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
<property name="value">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="spinBox_precision">
<property name="minimumSize">
<size>
<width>110</width>
<height>19</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="maximum">
<number>8</number>
</property>
<property name="value">
<number>2</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="direction_grid">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_up">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::UpArrow</enum>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
<widget class="QToolButton" name="toolButton_down">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::DownArrow</enum>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QToolButton" name="toolButton_left">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::LeftArrow</enum>
</property>
</widget>
</item>
<item row="0" column="2">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="3">
<widget class="QToolButton" name="toolButton_right">
<property name="minimumSize">
<size>
<width>26</width>
<height>26</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::RightArrow</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="2">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>18</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="motorControl_absolute">
<property name="minimumSize">
<size>
<width>261</width>
<height>195</height>
</size>
</property>
<property name="title">
<string>Move Absolute</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QCheckBox" name="checkBox_save_with_go">
<property name="text">
<string>Save position with Go</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-500.000000000000000</double>
</property>
<property name="maximum">
<double>500.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="pushButton_save">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_set">
<property name="text">
<string>Set</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_go_absolute">
<property name="text">
<string>Go</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="pushButton_stop">
<property name="text">
<string>Stop Movement</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget_tables">
<property name="enabled">
<bool>true</bool>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_coordinates">
<attribute name="title">
<string>Coordinates</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Entries Mode:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox_mode">
<item>
<property name="text">
<string>Individual</string>
</property>
</item>
<item>
<property name="text">
<string>Start/Stop</string>
</property>
</item>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_coordinates">
<property name="selectionMode">
<enum>QAbstractItemView::MultiSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<column>
<property name="text">
<string>Show</string>
</property>
</column>
<column>
<property name="text">
<string>Move</string>
</property>
</column>
<column>
<property name="text">
<string>Tag</string>
</property>
</column>
<column>
<property name="text">
<string>X</string>
</property>
</column>
<column>
<property name="text">
<string>Y</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<widget class="QPushButton" name="pushButton_resize_table">
<property name="text">
<string>Resize Table</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QCheckBox" name="checkBox_resize_auto">
<property name="text">
<string>Resize Auto</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="pushButton_importCSV">
<property name="text">
<string>Import CSV</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pushButton_exportCSV">
<property name="text">
<string>Export CSV</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="pushButton_help">
<property name="text">
<string>Help</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_duplicate">
<property name="text">
<string>Duplicate Last Entry</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_settings">
<attribute name="title">
<string>Settings</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QGroupBox" name="motorLimits">
<property name="enabled">
<bool>false</bool>
</property>
<property name="title">
<string>Motor Limits</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="2" column="1">
<widget class="QPushButton" name="pushButton_updateLimits">
<property name="text">
<string>Update</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_Y_max">
<property name="text">
<string>+ Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_Y_min">
<property name="text">
<string>- Y</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_X_min">
<property name="text">
<string>- X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_X_max">
<property name="text">
<string>+ X</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="spinBox_y_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QDoubleSpinBox" name="spinBox_x_min">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="spinBox_x_max">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<double>-1000.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Plotting Options</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="0" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_max_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>100</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="singleStep">
<number>100</number>
</property>
<property name="value">
<number>5000</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="2" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_scatter_size">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>15</number>
</property>
<property name="value">
<number>5</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_update_config">
<property name="text">
<string>Update Settings</string>
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QSpinBox" name="spinBox_num_dim_points">
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="minimum">
<number>10</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="singleStep">
<number>10</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_16">
<property name="text">
<string>N dim</string>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<widget class="QPushButton" name="pushButton_enableGUI">
<property name="text">
<string>Enable Control GUI</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_queue">
<attribute name="title">
<string>Queue</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Work in progress</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_5">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Reset Queue</string>
</property>
</widget>
</item>
<item>
<widget class="QTableWidget" name="tableWidget_2">
<property name="enabled">
<bool>false</bool>
</property>
<column>
<property name="text">
<string>queueID</string>
</property>
</column>
<column>
<property name="text">
<string>scan_id</string>
</property>
</column>
<column>
<property name="text">
<string>is_scan</string>
</property>
</column>
<column>
<property name="text">
<string>type</string>
</property>
</column>
<column>
<property name="text">
<string>scan_number</string>
</property>
</column>
<column>
<property name="text">
<string>IQ status</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>GraphicsLayoutWidget</class>
<extends>QGraphicsView</extends>
<header>pyqtgraph.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -7,8 +7,7 @@ import sys
from bec_ipython_client.main import BECIPythonClient
from qtpy.QtWidgets import QApplication
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from tictactoe import TicTacToe
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -2,9 +2,8 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.examples.plugin_example_pyside.tictactoeplugin import TicTacToePlugin
from tictactoe import TicTacToe
from tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin

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

@@ -1,13 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
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
from qtpy.QtGui import QIcon
from tictactoe import TicTacToe
from tictactoetaskmenu import TicTacToeTaskMenuFactory
DOM_XML = """
<ui language='c++'>
@@ -27,8 +24,6 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -43,10 +38,10 @@ class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "Games"
return ""
def icon(self):
return designer_material_icon("sports_esports")
return QIcon()
def includeFile(self):
return "tictactoe"

View File

@@ -1,12 +1,11 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Slot
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from tictactoe import TicTacToe
class TicTacToeDialog(QDialog): # pragma: no cover

View File

@@ -1,214 +0,0 @@
import functools
import sys
import traceback
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
to the passed function, to display errors instead of potentially raising an exception
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
otherwise error display is left to the original exception hook
"""
popup_error = bool(slot_kwargs.pop("popup_error", False))
def error_managed(method):
@Slot(*slot_args, **slot_kwargs)
@functools.wraps(method)
def wrapper(*args, **kwargs):
try:
return method(*args, **kwargs)
except Exception:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
return wrapper
return error_managed
class WarningPopupUtility(QObject):
"""
Utility class to show warning popups in the application.
"""
@SafeSlot(str, str, str, QWidget)
def show_warning_message(self, title, message, detailed_text, widget):
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle(title)
msg.setText(message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.exec_()
def show_warning(self, title: str, message: str, detailed_text: str, widget: QWidget = None):
"""
Show a warning message with the given title, message, and detailed text.
Args:
title (str): The title of the warning message.
message (str): The main text of the warning message.
detailed_text (str): The detailed text to show when the user expands the message.
widget (QWidget): The parent widget for the message box.
"""
self.show_warning_message(title, message, detailed_text, widget)
_popup_utility_instance = None
class _ErrorPopupUtility(QObject):
"""
Utility class to manage error popups in the application to show error messages to the users.
This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
"""
error_occurred = Signal(str, str, QWidget)
def __init__(self, parent=None):
super().__init__(parent=parent)
self.error_occurred.connect(self.show_error_message)
self.enable_error_popup = False
self._initialized = True
sys.excepthook = self.custom_exception_hook
@SafeSlot(str, str, QWidget)
def show_error_message(self, title, message, widget):
detailed_text = self.format_traceback(message)
error_message = self.parse_error_message(detailed_text)
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle(title)
msg.setText(error_message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
msg.setMinimumWidth(600)
msg.setMinimumHeight(400)
msg.exec_()
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.
Args:
traceback_message(str): The traceback message to be formatted.
Returns:
str: The formatted traceback message.
"""
formatted_lines = []
lines = traceback_message.split("\n")
for line in lines:
formatted_lines.append(" " + line) # Add indentation to each line
return "\n".join(formatted_lines)
def parse_error_message(self, traceback_message):
lines = traceback_message.split("\n")
error_message = "Error occurred. See details."
capture = False
captured_message = []
for line in lines:
if "raise" in line:
capture = True
continue
if capture:
if line.strip() and not line.startswith(" File "):
captured_message.append(line.strip())
else:
break
if captured_message:
error_message = " ".join(captured_message)
return error_message
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.parent(),
)
else:
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
def enable_global_error_popups(self, state: bool):
"""
Enable or disable global error popups for all applications.
Args:
state(bool): True to enable error popups, False to disable error popups.
"""
self.enable_error_popup = bool(state)
def ErrorPopupUtility():
global _popup_utility_instance
if not _popup_utility_instance:
_popup_utility_instance = _ErrorPopupUtility()
return _popup_utility_instance
class ExampleWidget(QWidget): # pragma: no cover
"""
Example widget to demonstrate error handling with the ErrorPopupUtility.
Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.init_ui()
self.warning_utility = WarningPopupUtility(self)
def init_ui(self):
self.layout = QVBoxLayout(self)
# Button to trigger method with error handling
self.error_button = QPushButton("Trigger Handled Error", self)
self.error_button.clicked.connect(self.method_with_error_handling)
self.layout.addWidget(self.error_button)
# Button to trigger method without error handling
self.normal_button = QPushButton("Trigger Normal Error", self)
self.normal_button.clicked.connect(self.method_without_error_handling)
self.layout.addWidget(self.normal_button)
# Button to trigger warning popup
self.warning_button = QPushButton("Trigger Warning", self)
self.warning_button.clicked.connect(self.trigger_warning)
self.layout.addWidget(self.warning_button)
@SafeSlot(popup_error=True)
def method_with_error_handling(self):
"""This method raises an error and the exception is handled by the decorator."""
raise ValueError("This is a handled error.")
@SafeSlot()
def method_without_error_handling(self):
"""This method raises an error and the exception is not handled here."""
raise ValueError("This is an unhandled error.")
@SafeSlot()
def trigger_warning(self):
"""Trigger a warning using the WarningPopupUtility."""
self.warning_utility.show_warning(
title="Warning",
message="This is a warning message.",
detailed_text="This is the detailed text of the warning message.",
widget=self,
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
widget = ExampleWidget()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,47 +0,0 @@
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

@@ -1,119 +0,0 @@
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
class SettingWidget(QWidget):
"""
Abstract class for a settings widget to enforce the implementation of the accept_changes and display_current_settings.
Can be used for toolbar actions to display the settings of a widget.
Args:
target_widget (QWidget): The widget that the settings will be taken from and applied to.
"""
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.target_widget = None
def set_target_widget(self, target_widget: QWidget):
self.target_widget = target_widget
@Slot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
pass
@Slot(dict)
def display_current_settings(self, config_dict: dict):
"""
Displays the current settings of the target widget in the settings widget.
Args:
config_dict(dict): The current settings of the target widget.
"""
pass
class SettingsDialog(QDialog):
"""
Dialog to display and edit the settings of a widget with accept and cancel buttons.
Args:
parent (QWidget): The parent widget of the dialog.
target_widget (QWidget): The widget that the settings will be taken from and applied to.
settings_widget (SettingWidget): The widget that will display the settings.
"""
def __init__(
self,
parent=None,
settings_widget: SettingWidget = None,
window_title: str = "Settings",
config: dict = None,
*args,
**kwargs,
):
super().__init__(parent, *args, **kwargs)
self.setModal(False)
self.setWindowTitle(window_title)
self.widget = settings_widget
self.widget.set_target_widget(parent)
if config is None:
config = parent.get_config()
self.widget.display_current_settings(config)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.apply_button = QPushButton("Apply")
button_layout = QHBoxLayout()
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Cancel))
button_layout.addWidget(self.apply_button)
button_layout.addWidget(self.button_box.button(QDialogButtonBox.Ok))
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self.apply_button.clicked.connect(self.apply_changes)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
self.layout.addWidget(self.widget)
self.layout.addLayout(button_layout)
ok_button = self.button_box.button(QDialogButtonBox.Ok)
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
@Slot()
def accept(self):
"""
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
super().accept()
@Slot()
def apply_changes(self):
"""
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

@@ -1,252 +0,0 @@
# pylint: disable=no-name-in-module
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Literal
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMenu, QToolBar, QToolButton, QWidget
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
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.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
self.icon_path = (
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
)
self.tooltip = tooltip
self.checkable = checkable
self.action = None
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action or widget to.
target (QWidget): The target widget for the action.
"""
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
toolbar.addSeparator()
class IconAction(ToolBarAction):
"""
Action with an icon for the toolbar.
Args:
icon_path (str): The path to the icon file.
tooltip (str): The tooltip for the action.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
super().__init__(icon_path, tooltip, checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = QIcon()
icon.addFile(self.icon_path, size=QSize(20, 20))
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
class MaterialIconAction:
"""
Action with a Material icon for the toolbar.
Args:
icon_path (str, optional): The name of the Material icon. Defaults to None.
tooltip (bool, 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.
"""
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,
):
self.icon_name = icon_name
self.tooltip = tooltip
self.checkable = checkable
self.action = None
self.filled = filled
self.color = color
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = self.get_icon()
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
def get_icon(self):
icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
return icon
class DeviceSelectionAction(ToolBarAction):
"""
Action for selecting a device in a combobox.
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str, device_combobox):
super().__init__()
self.label = label
self.device_combobox = device_combobox
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
layout = QHBoxLayout(widget)
label = QLabel(f"{self.label}")
layout.addWidget(label)
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
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):
super().__init__()
self.label = label
self.widget = widget
def add_to_toolbar(self, toolbar, target):
widget = QWidget()
layout = QHBoxLayout(widget)
if self.label is not None:
label = QLabel(f"{self.label}")
layout.addWidget(label)
layout.addWidget(self.widget)
toolbar.addWidget(widget)
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
Args:
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):
super().__init__(icon_path, label)
self.actions = actions
self.widgets = defaultdict(dict)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(action.tooltip, target)
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
button.setMenu(menu)
toolbar.addWidget(button)
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.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
"""
def __init__(self, parent=None, actions: dict | None = None, target_widget=None):
super().__init__(parent)
self.widgets = defaultdict(dict)
self.set_background_color()
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):
"""Populates the toolbar with a set of actions.
Args:
actions (list[ToolBarAction]): A list of action creators to populate the toolbar.
target_widget (QWidget): The widget that the actions will target.
"""
self.clear()
for action_id, action in actions.items():
action.add_to_toolbar(self, target_widget)
self.widgets[action_id] = action
def set_background_color(self):
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
self.setStyleSheet("QToolBar { background-color: rgba(0, 0, 0, 0); border: none; }")

View File

@@ -10,11 +10,9 @@ import yaml
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -66,29 +64,15 @@ class Worker(QRunnable):
class BECConnector:
"""Connection mixin class to handle BEC client and device manager"""
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
EXIT_HANDLERS = {}
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@pyqtSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
print("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
print("Shutting down BEC Client", repr(client))
client.shutdown()
BECConnector.EXIT_HANDLERS[self.client] = terminate
QApplication.instance().aboutToQuit.connect(terminate)
if config:
self.config = config
self.config.widget_class = self.__class__.__name__
@@ -106,14 +90,9 @@ class BECConnector:
self.gui_id = self.config.gui_id
# register widget to rpc register
# be careful: when registering, and the object is not a BECWidget,
# cleanup has to called manually since there is no 'closeEvent'
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
# Error popups
self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance()
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
@@ -300,3 +279,16 @@ class BECConnector:
return self.config.model_dump()
else:
return self.config
def cleanup(self):
"""Cleanup the widget."""
self.rpc_register.remove_rpc(self)
all_connections = self.rpc_register.list_all_connections()
if len(all_connections) == 0:
print("No more connections. Shutting down GUI BEC client.")
self.bec_dispatcher.disconnect_all()
self.client.shutdown()
# def closeEvent(self, event):
# self.cleanup()
# super().closeEvent(event)

View File

@@ -6,9 +6,7 @@ import sys
import sysconfig
from pathlib import Path
from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
@@ -23,19 +21,6 @@ if PYSIDE6:
import bec_widgets
def designer_material_icon(icon_name: str) -> QIcon:
"""
Create a QIcon for the BECDesigner with the given material icon name.
Args:
icon_name (str): The name of the material icon.
Returns:
QIcon: The QIcon for the material icon.
"""
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
def list_editable_packages() -> set[str]:
"""
List all editable packages in the environment.
@@ -88,28 +73,26 @@ def patch_designer(): # pragma: no cover
os.environ["PY_MAJOR_VERSION"] = str(major_version)
os.environ["PY_MINOR_VERSION"] = str(minor_version)
if sys.platform == "win32":
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
else:
if sys.platform == "linux":
suffix = f"{sys.abiflags}.so"
env_var = "LD_PRELOAD"
elif sys.platform == "darwin":
suffix = ".dylib"
env_var = "DYLD_INSERT_LIBRARIES"
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
if sys.platform == "linux":
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{suffix}"
library_name = f"libpython{version}{sys.abiflags}.so"
if is_pyenv_python():
library_name = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ["LD_PRELOAD"] = library_name
elif sys.platform == "darwin":
library_name = f"libpython{major_version}.{minor_version}.dylib"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ[env_var] = lib_path
os.environ["DYLD_INSERT_LIBRARIES"] = lib_path
if is_pyenv_python() or is_virtual_env():
# append all editable packages to the PYTHONPATH
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
elif sys.platform == "win32":
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])

View File

@@ -8,7 +8,7 @@ import redis
from bec_lib.client import BECClient
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import PYQT5, PYQT6, PYSIDE2, PYSIDE6, QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -75,6 +75,7 @@ class BECDispatcher:
_instance = None
_initialized = False
qapp = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
@@ -86,6 +87,9 @@ class BECDispatcher:
if self._initialized:
return
if not QCoreApplication.instance():
BECDispatcher.qapp = QCoreApplication([])
self._slots = collections.defaultdict(set)
self.client = client
@@ -119,13 +123,20 @@ class BECDispatcher:
cls._instance = None
cls._initialized = False
if not cls.qapp:
return
# shutdown QCoreApp if it exists
if PYQT5 or PYQT6:
cls.qapp.exit()
elif PYSIDE2 or PYSIDE6:
cls.qapp.shutdown()
cls.qapp = None
def connect_slot(
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
**kwargs,
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
@@ -133,18 +144,11 @@ class BECDispatcher:
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
"""
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot, **kwargs)
self.client.connector.register(topics, cb=slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics(Union[str, list]): The topic(s) to disconnect from
"""
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
@@ -155,17 +159,11 @@ class BECDispatcher:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
self._slots[slot].difference_update(set(topics_str))
if not self._slots[slot]:
del self._slots[slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
Disconnect all slots from a topic.
Args:
topics(Union[str, list]): The topic(s) to disconnect from
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
@@ -175,11 +173,4 @@ class BECDispatcher:
del self._slots[slot]
def disconnect_all(self, *args, **kwargs):
"""
Disconnect all slots from all topics.
Args:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
self.disconnect_topics(self.client.connector._topics_cb)

View File

@@ -1,32 +0,0 @@
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
class BECWidget(BECConnector):
"""Mixin class for all BEC widgets, to handle cleanup"""
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client, config, gui_id)
# Set the theme to auto if it is not set yet
app = QApplication.instance()
if not hasattr(app, "theme"):
set_theme("dark")
def cleanup(self):
"""Cleanup the widget."""
def closeEvent(self, event):
self.rpc_register.remove_rpc(self)
try:
self.cleanup()
finally:
super().closeEvent(event)

View File

@@ -1,75 +1,10 @@
import itertools
import re
from typing import Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication, QPushButton, QToolButton
def get_theme_palette():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme["theme"]
return bec_qthemes.load_palette(theme)
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme)
pg.setConfigOption("background", "w" if app.theme["theme"] == "light" else "k")
apply_theme(theme)
# pylint: disable=protected-access
if theme != "auto":
return
def callback():
app.theme["theme"] = listener._theme.lower()
apply_theme(listener._theme.lower())
listener = OSThemeSwitchListener(callback)
app.installEventFilter(listener)
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
app = QApplication.instance()
# go through all pyqtgraph widgets and set background
children = itertools.chain.from_iterable(
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
)
for pg_widget in children:
pg_widget.setBackground("k" if theme == "dark" else "w")
dark_mode_buttons = [
button
for button in app.topLevelWidgets()
if hasattr(button, "dark_mode_enabled")
and hasattr(button, "mode_button")
and isinstance(button.mode_button, (QPushButton, QToolButton))
]
for button in dark_mode_buttons:
button.dark_mode_enabled = theme == "dark"
button.update_mode_button()
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
class Colors:
@@ -120,19 +55,8 @@ class Colors:
angles = Colors.golden_ratio(len(cmap_colors))
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
colors = []
ii = 0
while len(colors) < num:
color_index = int(color_selection[ii])
color = cmap_colors[color_index]
app = QApplication.instance()
if hasattr(app, "theme") and app.theme["theme"] == "light":
background = 255
else:
background = 0
if np.abs(np.mean(color[:3] * 255) - background) < 50:
ii += 1
continue
for ii in color_selection[:num]:
color = cmap_colors[int(ii)]
if format.upper() == "HEX":
colors.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
@@ -141,7 +65,6 @@ class Colors:
colors.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
ii += 1
return colors
@staticmethod

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import itertools
from typing import Type

View File

@@ -1,16 +1,12 @@
from collections import defaultdict
import numpy as np
import pyqtgraph as pg
# from qtpy.QtCore import QObject, pyqtSignal
from qtpy.QtCore import QObject, Qt
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
class Crosshair(QObject):
positionChanged = pyqtSignal(tuple)
positionClicked = pyqtSignal(tuple)
# Signal for 1D plot
coordinatesChanged1D = pyqtSignal(tuple)
coordinatesClicked1D = pyqtSignal(tuple)
@@ -30,13 +26,10 @@ class Crosshair(QObject):
super().__init__(parent)
self.is_log_y = None
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self.precision = precision
self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
self.h_line.skip_auto_range = True
self.plot_item.addItem(self.v_line, ignoreBounds=True)
self.plot_item.addItem(self.h_line, ignoreBounds=True)
self.proxy = pg.SignalProxy(
@@ -44,75 +37,74 @@ class Crosshair(QObject):
)
self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked)
self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives)
self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log)
self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log)
# Initialize markers
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
self.update_markers()
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
# Clear existing markers
for marker in self.marker_moved_1d + self.marker_clicked_1d:
self.plot_item.removeItem(marker)
if self.marker_2d:
self.plot_item.removeItem(self.marker_2d)
# Create new markers
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
if item.name() in self.marker_moved_1d:
continue
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
marker_moved = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_moved.skip_auto_range = True
self.marker_moved_1d[item.name()] = marker_moved
marker_clicked = pg.ScatterPlotItem(
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
)
self.marker_moved_1d.append(marker_moved)
self.plot_item.addItem(marker_moved)
# Create glowing effect markers for clicked events
marker_clicked_list = []
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
marker_clicked = pg.ScatterPlotItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
)
marker_clicked.skip_auto_range = True
self.marker_clicked_1d[item.name()] = marker_clicked
marker_clicked_list.append(marker_clicked)
self.plot_item.addItem(marker_clicked)
self.marker_clicked_1d.append(marker_clicked_list)
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None:
continue
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
)
self.plot_item.addItem(self.marker_2d)
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
def snap_to_data(self, x, y) -> tuple:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x: The x-coordinate of the mouse cursor
y: The y-coordinate of the mouse cursor
x: The x-coordinate
y: The y-coordinate
Returns:
tuple: x and y values snapped to the nearest data
tuple: The nearest x and y values
"""
y_values = defaultdict(list)
x_values = defaultdict(list)
y_values_1d = []
x_values_1d = []
image_2d = None
# Iterate through items in the plot
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
name = item.name()
plot_data = item._getDisplayDataset()
if plot_data is None:
continue
x_data, y_data = plot_data.x, plot_data.y
x_data, y_data = item.xData, item.yData
if x_data is not None and y_data is not None:
if self.is_log_x:
min_x_data = np.min(x_data[x_data > 0])
@@ -120,25 +112,25 @@ class Crosshair(QObject):
min_x_data = np.min(x_data)
max_x_data = np.max(x_data)
if x < min_x_data or x > max_x_data:
y_values[name] = None
x_values[name] = None
continue
return None, None
closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data)
y_values[name] = closest_y
x_values[name] = closest_x
y_values_1d.append(closest_y)
x_values_1d.append(closest_x)
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor
image_2d = item.image
# clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
v is None for v in y_values.values()
):
# Handle 1D plot
if y_values_1d:
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
return None, None
return x_values, y_values
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
return closest_x, y_values_1d
# Handle 2D plot
if image_2d is not None:
x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1))
y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1))
return x_idx, y_idx
return None, None
@@ -164,9 +156,8 @@ class Crosshair(QObject):
Args:
event: The mouse moved event
"""
self.check_log()
pos = event[0]
self.update_markers()
self.positionChanged.emit((pos.x(), pos.y()))
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
self.v_line.setPos(mouse_point.x())
@@ -177,34 +168,27 @@ class Crosshair(QObject):
x = 10**x
if self.is_log_y:
y = 10**y
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
x, y_values = self.snap_to_data(x, y)
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
name = item.name()
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
if x is None or all(v is None for v in y_values):
return
coordinate_to_emit = (
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
self.marker_moved_1d[i].setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
if x is None or y_values is None:
return
coordinate_to_emit = (x, y_values)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -212,69 +196,40 @@ class Crosshair(QObject):
Args:
event: The mouse clicked event
"""
# we only accept left mouse clicks
if event.button() != Qt.MouseButton.LeftButton:
return
self.update_markers()
self.check_log()
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
x, y = mouse_point.x(), mouse_point.y()
self.positionClicked.emit((x, y))
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
x, y_values = self.snap_to_data(x, y)
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem):
name = item.name()
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_clicked_1d[name].setData([x], [y])
coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision))
if x is None or all(v is None for v in y_values):
return
coordinate_to_emit = (
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
for i, y_val in enumerate(y_values):
for marker in self.marker_clicked_1d[i]:
marker.setData(
[x if not self.is_log_x else np.log10(x)],
[y_val if not self.is_log_y else np.log10(y_val)],
)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
if x is None or y_values is None:
return
coordinate_to_emit = (x, y_values)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
marker.clear()
for marker in self.marker_clicked_1d.values():
marker.clear()
self.marker_2d.setPos([x, y_values])
def check_log(self):
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked()
self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked()
self.clear_markers()
def check_derivatives(self):
"""Checks if the derivatives are enabled and updates the internal state accordingly."""
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
def cleanup(self):
self.v_line.deleteLater()
self.h_line.deleteLater()
self.clear_markers()

View File

@@ -19,7 +19,7 @@ class EntryValidator:
device = self.devices[name]
description = device.describe()
if entry is None or entry == "":
if entry is None:
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")

View File

@@ -4,7 +4,7 @@ import re
from qtpy.QtCore import QObject
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock"]
class DesignerPluginInfo:
@@ -58,12 +58,11 @@ class DesignerPluginGenerator:
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
def run(self, validate=True):
def run(self):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
return
if validate:
self._check_class_validity()
self._check_class_validity()
self._load_templates()
self._write_templates()
@@ -143,7 +142,7 @@ class DesignerPluginGenerator:
if __name__ == "__main__": # pragma: no cover
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.spinner.spinner import SpinnerWidget
from bec_widgets.widgets.dock import BECDockArea
generator = DesignerPluginGenerator(SpinnerWidget)
generator.run(validate=False)
generator = DesignerPluginGenerator(BECDockArea)
generator.run()

View File

@@ -2,8 +2,8 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_designer import designer_material_icon
{widget_import}
DOM_XML = """
@@ -30,7 +30,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
return ""
def icon(self):
return designer_material_icon({plugin_name_pascal}.ICON_NAME)
return QIcon()
def includeFile(self):
return "{plugin_name_snake}"

View File

@@ -1,13 +1,12 @@
import importlib
import inspect
import os
from dataclasses import dataclass
from typing import Literal
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
def get_plugin_widgets() -> dict[str, BECConnector]:
@@ -45,74 +44,9 @@ def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
@dataclass
class BECClassInfo:
name: str
module: str
file: str
obj: type
is_connector: bool = False
is_widget: bool = False
is_top_level: bool = False
class BECClassContainer:
def __init__(self):
self._collection = []
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
Args:
class_info(BECClassInfo): The class information
"""
self.collection.append(class_info)
@property
def collection(self):
"""
Get the collection of classes.
"""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_top_level]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
def get_rpc_classes(repo_name: str) -> BECClassContainer:
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
@@ -122,7 +56,8 @@ def get_rpc_classes(repo_name: str) -> BECClassContainer:
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
collection = BECClassContainer()
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
@@ -143,16 +78,11 @@ def get_rpc_classes(repo_name: str) -> BECClassContainer:
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):
class_info.is_widget = True
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
collection.add_class(class_info)
top_level_classes.append(obj)
return collection
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}

View File

@@ -1,92 +0,0 @@
import os
import sys
from PIL import Image, ImageChops
from qtpy.QtGui import QPixmap
import bec_widgets
REFERENCE_DIR = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/references"
)
REFERENCE_DIR_FAILURES = os.path.join(
os.path.dirname(os.path.dirname(bec_widgets.__file__)), "tests/reference_failures"
)
def compare_images(image1_path: str, reference_image_path: str):
"""
Load two images and compare them pixel by pixel
Args:
image1_path(str): The path to the first image
reference_image_path(str): The path to the reference image
Raises:
ValueError: If the images are different
"""
image1 = Image.open(image1_path)
image2 = Image.open(reference_image_path)
if image1.size != image2.size:
raise ValueError("Image size has changed")
diff = ImageChops.difference(image1, image2)
if diff.getbbox():
# copy image1 to the reference directory to upload as artifact
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, os.path.basename(image1_path))
image1.save(image_name)
print(f"Image saved to {image_name}")
raise ValueError("Images are different")
def snap_and_compare(widget: any, output_directory: str, suffix: str = ""):
"""
Save a rendering of a widget and compare it to a reference image
Args:
widget(any): The widget to render
output_directory(str): The directory to save the image to
suffix(str): A suffix to append to the image name
Raises:
ValueError: If the images are different
Examples:
snap_and_compare(widget, tmpdir, suffix="started")
"""
if not isinstance(output_directory, str):
output_directory = str(output_directory)
os_suffix = sys.platform
name = (
f"{widget.__class__.__name__}_{suffix}_{os_suffix}.png"
if suffix
else f"{widget.__class__.__name__}_{os_suffix}.png"
)
# Save the widget to a pixmap
test_image_path = os.path.join(output_directory, name)
pixmap = QPixmap(widget.size())
widget.render(pixmap)
pixmap.save(test_image_path)
try:
reference_path = os.path.join(REFERENCE_DIR, f"{widget.__class__.__name__}")
reference_image_path = os.path.join(reference_path, name)
if not os.path.exists(reference_image_path):
raise ValueError(f"Reference image not found: {reference_image_path}")
compare_images(test_image_path, reference_image_path)
except ValueError:
image = Image.open(test_image_path)
os.makedirs(REFERENCE_DIR_FAILURES, exist_ok=True)
image_name = os.path.join(REFERENCE_DIR_FAILURES, name)
image.save(image_name)
print(f"Image saved to {image_name}")
raise

View File

@@ -1,45 +1,27 @@
import os
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy import QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_rpc_classes
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
self.baseinstance = baseinstance
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
widget = self.custom_widgets[class_name](parent)
widget.setObjectName(name)
return widget
return super().createWidget(class_name, parent, name)
class UILoader:
"""Universal UI loader for PyQt6 and PySide6."""
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
def __init__(self, parent=None):
self.parent = parent
if QT_VERSION.startswith("5"):
# PyQt5 or PySide2
from qtpy import uic
widgets = get_rpc_classes("bec_widgets").top_level_classes
self.loader = uic.loadUi
elif QT_VERSION.startswith("6"):
# PyQt6 or PySide6
try:
from PySide6.QtUiTools import QUiLoader
self.custom_widgets = {widget.__name__: widget for widget in widgets}
self.loader = self.load_ui_pyside6
except ImportError:
from PyQt6.uic import loadUi
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:
self.loader = self.load_ui_pyqt6
else:
raise ImportError("No compatible Qt bindings found.")
self.loader = loadUi
def load_ui_pyside6(self, ui_file, parent=None):
"""
@@ -51,8 +33,9 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
from PySide6.QtUiTools import QUiLoader
loader = CustomUiLoader(parent, self.custom_widgets)
loader = QUiLoader(parent)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")
@@ -60,71 +43,6 @@ class UILoader:
file.close()
return widget
def load_ui_pyqt6(self, ui_file, parent=None):
"""
Specific loader for PyQt6 using loadUi.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
from PyQt6.uic.Loader.loader import DynamicUILoader
class CustomDynamicUILoader(DynamicUILoader):
def __init__(self, package, custom_widgets: dict = None):
super().__init__(package)
self.custom_widgets = custom_widgets or {}
def _handle_custom_widgets(self, el):
"""Handle the <customwidgets> element."""
def header2module(header):
"""header2module(header) -> string
Convert paths to C++ header files to according Python modules
>>> header2module("foo/bar/baz.h")
'foo.bar.baz'
"""
if header.endswith(".h"):
header = header[:-2]
mpath = []
for part in header.split("/"):
# Ignore any empty parts or those that refer to the current
# directory.
if part not in ("", "."):
if part == "..":
# We should allow this for Python3.
raise SyntaxError(
"custom widget header file name may not contain '..'."
)
mpath.append(part)
return ".".join(mpath)
for custom_widget in el:
classname = custom_widget.findtext("class")
header = custom_widget.findtext("header")
if header:
header = self._translate_bec_widgets_header(header)
self.factory.addCustomWidget(
classname,
custom_widget.findtext("extends") or "QWidget",
header2module(header),
)
def _translate_bec_widgets_header(self, header):
for name, value in self.custom_widgets.items():
if header == DesignerPluginInfo.pascal_to_snake(name):
return value.__module__
return header
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
def load_ui(self, ui_file, parent=None):
"""
Universal UI loader method.

View File

@@ -1,20 +1,15 @@
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QHBoxLayout, QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from qtpy.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
class BECQueue(BECWidget, QWidget):
class BECQueue(BECConnector, QTableWidget):
"""
Widget to display the BEC queue.
"""
ICON_NAME = "edit_note"
def __init__(
self,
parent: QWidget | None = None,
@@ -23,14 +18,10 @@ class BECQueue(BECWidget, QWidget):
gui_id: str = None,
):
super().__init__(client, config, gui_id)
QWidget.__init__(self, parent=parent)
self.table = QTableWidget(self)
self.layout = QHBoxLayout(self)
self.layout.addWidget(self.table)
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
header = self.table.horizontalHeader()
QTableWidget.__init__(self, parent=parent)
self.setColumnCount(3)
self.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
header = self.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
self.reset_content()
@@ -46,8 +37,8 @@ class BECQueue(BECWidget, QWidget):
"""
# only show the primary queue for now
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
self.table.setRowCount(len(queue_info))
self.table.clearContents()
self.setRowCount(len(queue_info))
self.clearContents()
if not queue_info:
self.reset_content()
@@ -81,8 +72,6 @@ class BECQueue(BECWidget, QWidget):
Returns:
QTableWidgetItem: The formatted item.
"""
if not content or not isinstance(content, str):
content = ""
item = QTableWidgetItem(content)
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
return item
@@ -98,16 +87,16 @@ class BECQueue(BECWidget, QWidget):
status (str): The status.
"""
self.table.setItem(index, 0, self.format_item(scan_number))
self.table.setItem(index, 1, self.format_item(scan_type))
self.table.setItem(index, 2, self.format_item(status))
self.setItem(index, 0, self.format_item(scan_number))
self.setItem(index, 1, self.format_item(scan_type))
self.setItem(index, 2, self.format_item(status))
def reset_content(self):
"""
Reset the content of the table.
"""
self.table.setRowCount(1)
self.setRowCount(1)
self.set_row(0, "", "", "")

View File

@@ -1,11 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
DOM_XML = """
@@ -15,8 +13,6 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -31,10 +27,10 @@ class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Services"
return ""
def icon(self):
return designer_material_icon(BECQueue.ICON_NAME)
return QIcon()
def includeFile(self):
return "bec_queue"

View File

@@ -5,16 +5,15 @@ The widget automatically updates the status of all running BEC services, and dis
from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
@@ -24,18 +23,45 @@ if TYPE_CHECKING:
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
@dataclass
class BECServiceInfoContainer:
class BECStatusBoxConfig(ConnectionConfig):
pass
class BECServiceInfoContainer(BaseModel):
"""Container to store information about the BEC services."""
service_name: str
status: str
status: BECStatus | str = Field(
default="NOTCONNECTED",
description="The status of the service. Can be any of the BECStatus names, or NOTCONNECTED.",
)
info: dict
metrics: dict | None
model_config: dict = {"validate_assignment": True}
@field_validator("status")
@classmethod
def validate_status(cls, v):
"""Validate input for status. Accept BECStatus and NOTCONNECTED.
Args:
v (BECStatus | str): The input value.
Returns:
str: The validated status.
"""
if v in list(BECStatus.__members__.values()):
return v.name
if v in list(BECStatus.__members__.keys()) or v == "NOTCONNECTED":
return v
raise ValueError(
f"Status must be one of {BECStatus.__members__.values()} or 'NOTCONNECTED'. Input {v}"
)
class BECServiceStatusMixin(QObject):
"""Mixin to receive the latest service status from the BEC server and emit it via services_update signal.
"""A mixin class to update the service status, and metrics.
It emits a signal 'services_update' when the service status is updated.
Args:
client (BECClient): The client object to connect to the BEC server.
@@ -43,33 +69,29 @@ class BECServiceStatusMixin(QObject):
services_update = Signal(dict, dict)
ICON_NAME = "dns"
def __init__(self, parent, client: BECClient):
super().__init__(parent)
def __init__(self, client: BECClient):
super().__init__()
self.client = client
self._service_update_timer = QTimer()
self._service_update_timer.timeout.connect(self._get_service_status)
self._service_update_timer.start(1000)
def _get_service_status(self):
"""Get the latest service status from the BEC server."""
"""Pull latest service and metrics updates from REDIS for all services, and emit both via 'services_update' signal."""
# pylint: disable=protected-access
self.client._update_existing_services()
self.services_update.emit(self.client._services_info, self.client._services_metric)
def cleanup(self):
"""Cleanup the BECServiceStatusMixin."""
self._service_update_timer.stop()
self._service_update_timer.deleteLater()
class BECStatusBox(BECWidget, QWidget):
"""An autonomous widget to display the status of BEC services.
class BECStatusBox(BECConnector, QTreeWidget):
"""A widget to display the status of different BEC services.
This widget automatically updates the status of all running BEC services, and displays their status.
Information about the individual services is collapsible, and double clicking on
the individual service will display the metrics about the service.
Args:
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
box_name Optional(str): The name of the top service label. Defaults to "BEC Server".
service_name Optional(str): The name of the top service label. Defaults to "BEC Server".
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
gui_id Optional(str): The unique id for the widget. Defaults to None.
@@ -77,67 +99,58 @@ class BECStatusBox(BECWidget, QWidget):
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(BECServiceInfoContainer)
service_update = Signal(dict)
bec_core_state = Signal(str)
def __init__(
self,
parent=None,
box_name: str = "BEC Server",
service_name: str = "BEC Server",
client: BECClient = None,
config: BECStatusBoxConfig | dict = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.tree = QTreeWidget(self)
self.layout = QHBoxLayout(self)
if config is None:
config = BECStatusBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = BECStatusBoxConfig(**config)
super().__init__(client=client, config=config, gui_id=gui_id)
QTreeWidget.__init__(self, parent=parent)
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
self.service_name = service_name
self.config = config
self.bec_service_info_container = {}
self.tree_items = {}
self.tree_top_item = None
if not bec_service_status_mixin:
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
bec_service_status_mixin = BECServiceStatusMixin(client=self.client)
self.bec_service_status = bec_service_status_mixin
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
self.layout.addWidget(self.tree)
self.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
def init_ui(self) -> None:
"""Init the UI for the BECStatusBox widget, should only take place once."""
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
self.init_ui_tree_widget()
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
tree_item = QTreeWidgetItem()
tree_item.setExpanded(True)
tree_item.setDisabled(True)
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
self.tree.addTopLevelItem(tree_item)
self.tree.setItemWidget(tree_item, 0, top_label)
top_label = self._create_status_widget(self.service_name, status=BECStatus.IDLE)
self.tree_top_item = QTreeWidgetItem()
self.tree_top_item.setExpanded(True)
self.tree_top_item.setDisabled(True)
self.addTopLevelItem(self.tree_top_item)
self.setItemWidget(self.tree_top_item, 0, top_label)
self.service_update.connect(top_label.update_config)
self._initialized = True
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
self.tree.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
information about the service in the status_container.
information about the service in the bec_service_info_container.
Args:
service_name (str): The name of the service.
@@ -150,8 +163,16 @@ class BECStatusBox(BECWidget, QWidget):
"""
if info is None:
info = {}
self._update_status_container(service_name, status, info, metrics)
item = StatusItem(parent=self, config=self.status_container[service_name]["info"])
self._update_bec_service_container(service_name, status, info, metrics)
item = StatusItem(
parent=self,
config={
"service_name": service_name,
"status": status.name,
"info": info,
"metrics": metrics,
},
)
return item
@Slot(str)
@@ -162,35 +183,30 @@ class BECStatusBox(BECWidget, QWidget):
Args:
status (BECStatus): The state of the core services.
"""
self.status_container[self.box_name]["info"].status = status
self.service_update.emit(self.status_container[self.box_name]["info"])
self.bec_service_info_container[self.service_name].status = status
self.service_update.emit(self.bec_service_info_container[self.service_name].model_dump())
def _update_status_container(
def _update_bec_service_container(
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
) -> None:
"""Update the status_container with the newest status and metrics for the BEC service.
"""Update the bec_service_info_container with the newest status and metrics for the BEC service.
If information about the service already exists, it will create a new entry.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info (dict): The information about the service.
metrics (dict): The metrics of the service.
service_info (StatusMessage): A class containing the service status.
service_metric (ServiceMetricMessage): A class containing the service metrics.
"""
container = self.status_container[service_name].get("info", None)
container = self.bec_service_info_container.get(service_name, None)
if container:
container.status = status.name
container.status = status
container.info = info
container.metrics = metrics
return
service_info_item = BECServiceInfoContainer(
service_name=service_name,
status=status.name if isinstance(status, BECStatus) else status,
info=info,
metrics=metrics,
service_name=service_name, status=status, info=info, metrics=metrics
)
self.status_container[service_name].update({"info": service_info_item})
self.bec_service_info_container.update({service_name: service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
@@ -201,7 +217,7 @@ class BECStatusBox(BECWidget, QWidget):
services_info (dict): A dictionary containing the service status for all running BEC services.
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
"""
checked = [self.box_name]
checked = []
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)
@@ -209,19 +225,28 @@ class BECStatusBox(BECWidget, QWidget):
checked.append(service_name)
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name in self.status_container:
if not msg:
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
continue
self._update_status_container(service_name, msg.status, msg.info, metrics)
self.service_update.emit(self.status_container[service_name]["info"])
if service_name in self.tree_items:
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
item_widget = self._create_status_widget(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
item = QTreeWidgetItem()
item.setDisabled(True)
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
self.check_redundant_tree_items(checked)
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
"""Update the core services of BEC, and emit the updated status to the BECStatusBox.
"""Method to process status and metrics updates of core services (stored in CORE_SERVICES).
If a core services is not connected, it should not be removed from the status widget
Args:
services_info (dict): A dictionary containing the service status of different services.
@@ -230,28 +255,28 @@ class BECStatusBox(BECWidget, QWidget):
Returns:
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
"""
core_state = BECStatus.RUNNING
bec_core_state = "RUNNING"
for service_name in sorted(self.CORE_SERVICES):
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
msg = services_info.pop(service_name, None)
if service_name not in self.status_container:
if not msg:
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
continue
if not msg:
self.status_container[service_name]["info"].status = "NOTCONNECTED"
core_state = None
if service_name not in services_info:
self.bec_service_info_container[service_name].status = "NOTCONNECTED"
bec_core_state = "ERROR"
else:
self._update_status_container(service_name, msg.status, msg.info, metrics)
if core_state:
core_state = msg.status if msg.status.value < core_state.value else core_state
msg = services_info.pop(service_name)
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
bec_core_state = (
"RUNNING" if (msg.status.value > 1 and bec_core_state == "RUNNING") else "ERROR"
)
self.service_update.emit(self.status_container[service_name]["info"])
if service_name in self.tree_items:
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self.bec_core_state.emit(core_state.name if core_state else "NOTCONNECTED")
self.bec_core_state.emit(bec_core_state)
return services_info
def check_redundant_tree_items(self, checked: list) -> None:
@@ -260,12 +285,11 @@ class BECStatusBox(BECWidget, QWidget):
Args:
checked (list): A list of services that are currently running.
"""
to_be_deleted = [key for key in self.status_container if key not in checked]
to_be_deleted = [key for key in self.tree_items if key not in checked]
for key in to_be_deleted:
obj = self.status_container.pop(key)
item = obj["item"]
self.status_container[self.box_name]["item"].removeChild(item)
item, _ = self.tree_items.pop(key)
self.tree_top_item.removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
@@ -274,16 +298,30 @@ class BECStatusBox(BECWidget, QWidget):
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info (dict): The information about the service.
service_status_msg (StatusMessage): The status of the service.
metrics (dict): The metrics of the service.
"""
item_widget = self._create_status_widget(service_name, status, info, metrics)
item_widget = self._create_status_widget(
service_name=service_name, status=status, info=info, metrics=metrics
)
item = QTreeWidgetItem()
self.service_update.connect(item_widget.update_config)
self.status_container[self.box_name]["item"].addChild(item)
self.tree.setItemWidget(item, 0, item_widget)
self.status_container[service_name].update({"item": item, "widget": item_widget})
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.setHeaderHidden(True)
self.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
@@ -293,14 +331,13 @@ class BECStatusBox(BECWidget, QWidget):
item (QTreeWidgetItem): The item that was double clicked.
column (int): The column that was double clicked.
"""
for _, objects in self.status_container.items():
if objects["item"] == item:
objects["widget"].show_popup()
for _, (tree_item, status_widget) in self.tree_items.items():
if tree_item == item:
status_widget.show_popup()
def cleanup(self):
"""Cleanup the BECStatusBox widget."""
self.bec_service_status.cleanup()
return super().cleanup()
def closeEvent(self, event):
super().cleanup()
QTreeWidget().closeEvent(event)
def main():
@@ -309,7 +346,7 @@ def main():
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
qdarktheme.setup_theme("auto")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())

View File

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

View File

@@ -1,58 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
DOM_XML = """
<ui language='c++'>
<widget class='BECStatusBox' name='bec_status_box'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECStatusBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(BECStatusBox.ICON_NAME)
def includeFile(self):
return "bec_status_box"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECStatusBox"
def toolTip(self):
return "An autonomous widget to display the status of BEC services."
def whatsThis(self):
return self.toolTip()

View File

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

View File

@@ -2,30 +2,40 @@
The widget is bound to be used with the BECStatusBox widget."""
import enum
import os
import sys
from datetime import datetime
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import Field
from qtpy.QtCore import Qt, Slot
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
import bec_widgets
from bec_widgets.utils.bec_connector import ConnectionConfig
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = os.path.join(MODULE_PATH, "assets", "status_icons", "running.svg")
BUSY = os.path.join(MODULE_PATH, "assets", "status_icons", "refresh.svg")
IDLE = os.path.join(MODULE_PATH, "assets", "status_icons", "warning.svg")
ERROR = os.path.join(MODULE_PATH, "assets", "status_icons", "error.svg")
NOTCONNECTED = os.path.join(MODULE_PATH, "assets", "status_icons", "not_connected.svg")
RUNNING = "SP_DialogApplyButton"
BUSY = "SP_BrowserReload"
IDLE = "SP_MessageBoxWarning"
ERROR = "SP_DialogCancelButton"
NOTCONNECTED = "SP_TitleBarContextHelpButton"
class StatusWidgetConfig(ConnectionConfig):
"""Configuration class for the status item widget."""
service_name: str
status: str
info: dict
metrics: dict | None
icon_size: tuple = Field(default=(24, 24), description="The size of the icon in the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
class StatusItem(QWidget):
@@ -33,24 +43,23 @@ class StatusItem(QWidget):
Args:
parent: The parent widget.
config (dict): The configuration for the service, must be a BECServiceInfoContainer.
config (dict): The configuration for the service.
"""
def __init__(self, parent: QWidget = None, config=None):
QWidget.__init__(self, parent=parent)
def __init__(self, parent=None, config: dict = None):
if config is None:
# needed because we need parent to be the first argument for QT Designer
raise ValueError(
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
)
self.config = config
config = StatusWidgetConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = StatusWidgetConfig(**config)
self.config = config
QWidget.__init__(self, parent=parent)
self.parent = parent
self.layout = None
self.config = config
self._popup_label_ref = {}
self._label = None
self._icon = None
self.icon_size = (24, 24)
self._popup_label_ref = {}
self.init_ui()
def init_ui(self) -> None:
@@ -65,21 +74,23 @@ class StatusItem(QWidget):
self.update_ui()
@Slot(dict)
def update_config(self, config) -> None:
"""Update the config of the status item widget.
def update_config(self, config: dict) -> None:
"""Update the configuration of the status item widget.
This method is invoked from the parent widget.
The UI values are later updated based on the new configuration.
Args:
config (dict): Config updates from parent widget, must be a BECServiceInfoContainer.
config (dict): Config updates from parent widget.
"""
if self.config is None or config.service_name != self.config.service_name:
if config["service_name"] != self.config.service_name:
return
self.config = config
self.config.status = config["status"]
self.config.info = config["info"]
self.config.metrics = config["metrics"]
self.update_ui()
def update_ui(self) -> None:
"""Update the UI of the labels, and popup dialog."""
if self.config is None:
return
self.set_text()
self.set_status()
self._set_popup_text()
@@ -88,8 +99,8 @@ class StatusItem(QWidget):
"""Set the text of the QLabel basae on the config."""
service = self.config.service_name
status = self.config.status
if len(service.split("/")) > 1 and service.split("/")[0].startswith("BEC"):
service = service.split("/", maxsplit=1)[0] + "/..." + service.split("/")[1][-4:]
if "BECClient" in service.split("/"):
service = service.split("/")[0] + "/..." + service.split("/")[1][-4:]
if status == "NOTCONNECTED":
status = "NOT CONNECTED"
text = f"{service} is {status}"
@@ -97,9 +108,9 @@ class StatusItem(QWidget):
def set_status(self) -> None:
"""Set the status icon for the status item widget."""
icon_path = IconsEnum[self.config.status].value
icon = QIcon(icon_path)
self._icon.setPixmap(icon.pixmap(*self.icon_size))
icon_name = IconsEnum[self.config.status].value
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
self._icon.setPixmap(icon.pixmap(*self.config.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
def show_popup(self) -> None:
@@ -142,3 +153,19 @@ class StatusItem(QWidget):
def _cleanup_popup_label(self) -> None:
"""Cleanup the popup label."""
self._popup_label_ref.clear()
def main():
"""Run the status item widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = StatusItem()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

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

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_abort.button_abort import AbortButton
DOM_XML = """
<ui language='c++'>
<widget class='AbortButton' name='abort_button'>
</widget>
</ui>
"""
class AbortButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = AbortButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(AbortButton.ICON_NAME)
def includeFile(self):
return "abort_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "AbortButton"
def toolTip(self):
return "A button that abort the scan."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,49 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class AbortButton(BECWidget, QWidget):
"""A button that abort the scan."""
ICON_NAME = "cancel"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("cancel", color="#666666", filled=True)
self.button = QToolButton(icon=icon)
self.button.triggered.connect(self.abort_scan)
else:
self.button = QPushButton()
self.button.setText("Abort")
self.button.setStyleSheet(
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.abort_scan)
self.layout.addWidget(self.button)
@SafeSlot()
def abort_scan(
self,
): # , scan_id: str | None = None): #FIXME scan_id will be added when combining with Queue widget
"""
Abort the scan.
Args:
scan_id(str|None): The scan id to abort. If None, the current scan will be aborted.
"""
self.queue.request_scan_abortion()

View File

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

View File

@@ -1,42 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class ResetButton(BECWidget, QWidget):
"""A button that reset the scan queue."""
ICON_NAME = "restart_alt"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("restart_alt", color="#F19E39", filled=True)
self.button = QToolButton(icon=icon)
self.button.triggered.connect(self.reset_queue)
else:
self.button = QPushButton()
self.button.setText("Reset Queue")
self.button.setStyleSheet(
"background-color: #F19E39; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.reset_queue)
self.layout.addWidget(self.button)
@SafeSlot()
def reset_queue(self):
"""Stop the scan."""
self.queue.request_queue_reset()

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_reset.button_reset import ResetButton
DOM_XML = """
<ui language='c++'>
<widget class='ResetButton' name='reset_button'>
</widget>
</ui>
"""
class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ResetButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ResetButton.ICON_NAME)
def includeFile(self):
return "reset_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ResetButton"
def toolTip(self):
return "A button that reset the scan queue."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,42 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
class ResumeButton(BECWidget, QWidget):
"""A button that continue scan queue."""
ICON_NAME = "resume"
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
icon = material_icon("resume", color="#2793e8", filled=True)
self.button = QToolButton(icon=icon)
self.button.triggered.connect(self.continue_scan)
else:
self.button = QPushButton()
self.button.setText("Resume")
self.button.setStyleSheet(
"background-color: #2793e8; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.continue_scan)
self.layout.addWidget(self.button)
@SafeSlot()
def continue_scan(self):
"""Stop the scan."""
self.queue.request_scan_continuation()

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.button_resume.button_resume import ResumeButton
DOM_XML = """
<ui language='c++'>
<widget class='ResumeButton' name='resume_button'>
</widget>
</ui>
"""
class ResumeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ResumeButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ResumeButton.ICON_NAME)
def includeFile(self):
return "resume_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ResumeButton"
def toolTip(self):
return "A button that continue scan queue."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1 @@
from .stop_button.stop_button import StopButton

View File

@@ -0,0 +1,17 @@
import pyqtgraph as pg
class ColorButton(pg.ColorButton):
"""
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
Patches event loop of the ColorDialog, if opened in another QDialog.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def selectColor(self):
self.origColor = self.color()
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()

View File

@@ -0,0 +1,32 @@
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StopButton(BECConnector, QPushButton):
"""A button that stops the current scan."""
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.setText("Stop")
self.setStyleSheet("background-color: #cc181e; color: white")
self.clicked.connect(self.stop_scan)
def stop_scan(self):
"""Stop the scan."""
self.queue.request_scan_abortion()
self.queue.request_queue_reset()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StopButton()
widget.show()
sys.exit(app.exec_())

View File

@@ -1,45 +0,0 @@
from __future__ import annotations
from typing import Literal
import pyqtgraph as pg
class ColorButton(pg.ColorButton):
"""
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
Patches event loop of the ColorDialog, if opened in another QDialog.
"""
ICON_NAME = "colors"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def selectColor(self):
self.origColor = self.color()
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
"""
Get the color of the button in the specified format.
Args:
format(Literal["RGBA", "HEX"]): The format of the returned color.
Returns:
tuple|str: The color in the specified format.
"""
if format == "RGBA":
return self.color().getRgb()
if format == "HEX":
return self.color().name()
def cleanup(self):
"""
Clean up the ColorButton.
"""
self.colorDialog.close()
self.colorDialog.deleteLater()

View File

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

View File

@@ -1,55 +0,0 @@
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.color_button.color_button import ColorButton
DOM_XML = """
<ui language='c++'>
<widget class='ColorButton' name='color_button'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColorButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColorButton.ICON_NAME)
def includeFile(self):
return "color_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ColorButton"
def toolTip(self):
return "ColorButton which opens a color dialog."
def whatsThis(self):
return self.toolTip()

View File

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

View File

@@ -1,114 +0,0 @@
from __future__ import annotations
import pyqtgraph as pg
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtGui import QColor, QFontMetrics, QImage
from qtpy.QtWidgets import QApplication, QComboBox, QStyledItemDelegate, QVBoxLayout, QWidget
class ColormapDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super(ColormapDelegate, self).__init__(parent)
self.image_width = 25
self.image_height = 10
self.gap = 10
def paint(self, painter, option, index):
text = index.data()
colormap = pg.colormap.get(text)
colors = colormap.getLookupTable(start=0.0, stop=1.0, alpha=False)
font_metrics = QFontMetrics(painter.font())
text_width = font_metrics.width(text)
text_height = font_metrics.height()
total_height = max(text_height, self.image_height)
image = QImage(self.image_width, self.image_height, QImage.Format_RGB32)
for i in range(self.image_width):
color = QColor(*colors[int(i * (len(colors) - 1) / (self.image_width - 1))])
for j in range(self.image_height):
image.setPixel(i, j, color.rgb())
painter.drawImage(
option.rect.x(), option.rect.y() + (total_height - self.image_height) // 2, image
)
painter.drawText(
option.rect.x() + self.image_width + self.gap,
option.rect.y() + (total_height - text_height) // 2 + font_metrics.ascent(),
text,
)
class ColormapSelector(QWidget):
"""
Simple colormap combobox widget. By default it loads all the available colormaps in pyqtgraph.
"""
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
def __init__(self, parent=None, default_colormaps=None):
super().__init__(parent=parent)
self._colormaps = []
self.initUI(default_colormaps)
def initUI(self, default_colormaps=None):
self.layout = QVBoxLayout(self)
self.combo = QComboBox()
self.combo.setItemDelegate(ColormapDelegate())
self.combo.currentTextChanged.connect(self.colormap_changed)
self.available_colormaps = pg.colormap.listMaps()
if default_colormaps is None:
default_colormaps = self.available_colormaps
self.add_color_maps(default_colormaps)
self.layout.addWidget(self.combo)
@Slot()
def colormap_changed(self):
"""
Emit the colormap changed signal with the current colormap selected in the combobox.
"""
self.colormap_changed_signal.emit(self.combo.currentText())
def add_color_maps(self, colormaps=None):
"""
Add colormaps to the combobox.
Args:
colormaps(list): List of colormaps to add to the combobox. If None, all available colormaps are added.
"""
self.combo.clear()
if colormaps is not None:
for name in colormaps:
if name in self.available_colormaps:
self.combo.addItem(name)
else:
for name in self.available_colormaps:
self.combo.addItem(name)
self._colormaps = colormaps if colormaps is not None else self.available_colormaps
@Property("QStringList")
def colormaps(self):
"""
Property to get and set the colormaps in the combobox.
"""
return self._colormaps
@colormaps.setter
def colormaps(self, value):
"""
Set the colormaps in the combobox.
"""
if self._colormaps != value:
self._colormaps = value
self.add_color_maps(value)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
ex = ColormapSelector()
ex.show()
sys.exit(app.exec_())

View File

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

View File

@@ -1,58 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.colormap_selector.colormap_selector import ColormapSelector
DOM_XML = """
<ui language='c++'>
<widget class='ColormapSelector' name='colormap_selector'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class ColormapSelectorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColormapSelector(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColormapSelector.ICON_NAME)
def includeFile(self):
return "colormap_selector"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "ColormapSelector"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

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

View File

@@ -18,11 +18,10 @@ import pyte
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QSize, QSocketNotifier, Qt
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtCore import Slot as pyqtSlot
from qtpy.QtGui import QClipboard, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
@@ -223,7 +222,7 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
Start ``Backend`` process and render Pyte output as text.
"""
def __init__(self, parent, numColumns=125, numLines=50, **kwargs):
def __init__(self, parent, numColumns, numLines, **kwargs):
super().__init__(parent)
# file descriptor to communicate with the subprocess
@@ -290,7 +289,7 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
old["value"] = value
self.dataReady(self.backend.screen, reset_scroll=False)
@Slot(object)
@pyqtSlot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.

View File

@@ -1,96 +0,0 @@
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Slot
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import set_theme
class DarkModeButton(BECWidget, QWidget):
USER_ACCESS = ["toggle_dark_mode"]
ICON_NAME = "dark_mode"
def __init__(
self,
parent: QWidget | None = None,
client=None,
gui_id: str | None = None,
toolbar: bool = False,
) -> None:
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent)
self._dark_mode_enabled = False
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
if toolbar:
self.mode_button = QToolButton()
else:
self.mode_button = QPushButton()
self.dark_mode_enabled = self._get_qapp_dark_mode_state()
self.update_mode_button()
self.mode_button.clicked.connect(self.toggle_dark_mode)
self.layout.addWidget(self.mode_button)
self.setLayout(self.layout)
self.setFixedSize(40, 40)
def _get_qapp_dark_mode_state(self) -> bool:
"""
Get the dark mode state from the QApplication.
Returns:
bool: True if dark mode is enabled, False otherwise.
"""
qapp = QApplication.instance()
if hasattr(qapp, "theme") and qapp.theme["theme"] == "dark":
return True
return False
@Property(bool)
def dark_mode_enabled(self) -> bool:
"""
The dark mode state. If True, dark mode is enabled. If False, light mode is enabled.
"""
return self._dark_mode_enabled
@dark_mode_enabled.setter
def dark_mode_enabled(self, state: bool) -> None:
self._dark_mode_enabled = state
@Slot()
def toggle_dark_mode(self) -> None:
"""
Toggle the dark mode state. This will change the theme of the entire
application to dark or light mode.
"""
self.dark_mode_enabled = not self.dark_mode_enabled
self.update_mode_button()
set_theme("dark" if self.dark_mode_enabled else "light")
def update_mode_button(self):
icon = material_icon(
"light_mode" if self.dark_mode_enabled else "dark_mode",
size=(20, 20),
convert_to_pixmap=False,
)
self.mode_button.setIcon(icon)
self.mode_button.setToolTip("Set Light Mode" if self.dark_mode_enabled else "Set Dark Mode")
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("auto")
w = DarkModeButton()
w.show()
app.exec_()

View File

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

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
DOM_XML = """
<ui language='c++'>
<widget class='DarkModeButton' name='dark_mode_button'>
</widget>
</ui>
"""
class DarkModeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DarkModeButton(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(DarkModeButton.ICON_NAME)
def includeFile(self):
return "dark_mode_button"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DarkModeButton"
def toolTip(self):
return "Button to toggle between dark and light mode."
def whatsThis(self):
return self.toolTip()

View File

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

View File

@@ -1,109 +0,0 @@
import os
import re
from typing import Optional
from bec_lib.callback_handler import EventType
from pyqtgraph import SignalProxy
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QLineEdit, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.device_browser.device_item import DeviceItem
class DeviceBrowser(BECWidget, QWidget):
device_update: Signal = Signal()
ICON_NAME = "lists"
def __init__(
self,
parent: Optional[QWidget] = None,
config=None,
client=None,
gui_id: Optional[str] = None,
) -> None:
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent)
self.get_bec_shortcuts()
self.ui = None
self.ini_ui()
self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
)
self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_update.connect(self.update_device_list)
self.update_device_list()
def ini_ui(self) -> None:
"""
Initialize the UI by loading the UI file and setting the layout.
"""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
self.ui = UILoader(self).loader(ui_file_path)
layout.addWidget(self.ui)
self.setLayout(layout)
def on_device_update(self, action: str, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
Args:
action (str): The action that triggered the event.
content (dict): The content of the config update.
"""
if action in ["add", "remove", "reload"]:
self.device_update.emit()
@Slot()
def update_device_list(self) -> None:
"""
Update the device list based on the filter input.
There are two ways to trigger this function:
1. By changing the text in the filter input.
2. By emitting the device_update signal.
Either way, the function will filter the devices based on the filter input text and update the device list.
"""
filter_text = self.ui.filter_input.text()
try:
regex = re.compile(filter_text, re.IGNORECASE)
except re.error:
regex = None # Invalid regex, disable filtering
dev_list = self.ui.device_list
dev_list.clear()
for device in self.dev:
if regex is None or regex.search(device):
item = QListWidgetItem(dev_list)
device_item = DeviceItem(device)
# pylint: disable=protected-access
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
item.setSizeHint(device_item.sizeHint())
dev_list.setItemWidget(item, device_item)
dev_list.addItem(item)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
apply_theme("light")
widget = DeviceBrowser()
widget.show()
sys.exit(app.exec_())

View File

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

View File

@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>406</width>
<height>500</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="browser_group_box">
<property name="title">
<string>Device Browser</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="filter_layout">
<item>
<widget class="QLineEdit" name="filter_input">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListWidget" name="device_list"/>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,54 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.device_browser.device_browser import DeviceBrowser
DOM_XML = """
<ui language='c++'>
<widget class='DeviceBrowser' name='device_browser'>
</widget>
</ui>
"""
class DeviceBrowserPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DeviceBrowser(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(DeviceBrowser.ICON_NAME)
def includeFile(self):
return "device_browser"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DeviceBrowser"
def toolTip(self):
return "DeviceBrowser"
def whatsThis(self):
return self.toolTip()

View File

@@ -1 +0,0 @@
from .device_item import DeviceItem

View File

@@ -1,53 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtCore import QMimeData, Qt
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget
if TYPE_CHECKING:
from qtpy.QtGui import QMouseEvent
class DeviceItem(QWidget):
def __init__(self, device: str) -> None:
super().__init__()
self.device = device
layout = QHBoxLayout()
layout.setContentsMargins(10, 2, 10, 2)
self.label = QLabel(device)
layout.addWidget(self.label)
self.setLayout(layout)
self.setStyleSheet(
"""
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
"""
)
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.LeftButton:
drag = QDrag(self)
mime_data = QMimeData()
mime_data.setText(self.device)
drag.setMimeData(mime_data)
drag.exec_(Qt.MoveAction)
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
print("Double Clicked")
# TODO: Implement double click action for opening the device properties dialog
return super().mouseDoubleClickEvent(event)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = DeviceItem("Device")
widget.show()
sys.exit(app.exec_())

View File

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

View File

@@ -1,3 +0,0 @@
{
"files": ["device_combobox.py"]
}

View File

@@ -0,0 +1,2 @@
from .device_combobox.device_combobox import DeviceComboBox
from .device_line_edit.device_line_edit import DeviceLineEdit

View File

@@ -2,10 +2,10 @@ from typing import TYPE_CHECKING
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputBase, DeviceInputConfig
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.base_classes.device_input_base import DeviceInputConfig
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceComboBox(DeviceInputBase, QComboBox):
@@ -22,8 +22,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
ICON_NAME = "list_alt"
def __init__(
self,
parent=None,
@@ -36,7 +34,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.setMinimumSize(125, 26)
self.populate_combobox()
if arg_name is not None:
@@ -84,3 +82,11 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
def cleanup(self):
"""Cleanup the widget."""
super().cleanup()
def closeEvent(self, event):
super().cleanup()
QComboBox().closeEvent(event)

View File

@@ -0,0 +1,4 @@
{
"files": ["device_combobox.py", "launch_device_combobox.py",
]
}

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