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

Compare commits

..

2 Commits

Author SHA1 Message Date
David Perl
bcc246cf92 WIP drag and drop form widget 2025-04-17 16:33:40 +02:00
David Perl
550753078b WIP look at form widgets 2025-04-17 10:49:57 +02:00
174 changed files with 2640 additions and 4733 deletions

View File

@@ -1,28 +0,0 @@
name: Check PR status for branch
on:
workflow_call:
outputs:
branch-pr:
description: The PR number if the branch is in one
value: ${{ jobs.pr.outputs.branch-pr }}
jobs:
pr:
runs-on: "ubuntu-latest"
outputs:
branch-pr: ${{ steps.script.outputs.result }}
steps:
- uses: actions/github-script@v7
id: script
if: github.event_name == 'push'
with:
script: |
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: context.repo.owner + ':${{ github.ref_name }}'
})
if (prs.data.length) {
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
return prs.data[0]["number"]
}

View File

@@ -1,36 +0,0 @@
name: Full CI
on: [push, pull_request]
permissions:
pull-requests: write
jobs:
check_pr_status:
uses: ./.github/workflows/check_pr.yml
formatter:
needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test-matrix:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml
generate-cli-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml

View File

@@ -1,48 +0,0 @@
name: Run Pytest with Coverage
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Conda install and run pytest
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices
pip install -e .[dev,pyside6]
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

View File

@@ -1,61 +0,0 @@
name: Formatter and Pylint jobs
on: [workflow_call]
jobs:
Formatter:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Run black and isort
run: |
pip install black isort
pip install -e .[dev]
black --check --diff --color .
isort --check --diff ./
Pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint pylint-exit anybadge
- name: Run Pylint
run: |
mkdir -p ./pylint
set +e
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
pylint-exit $?
set -e
- name: Extract Pylint Score
id: score
run: |
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: Create Badge
run: |
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: pylint-artifacts
path: |
# ./pylint/pylint.log # not sure why this isn't working
./pylint/pylint.svg

View File

@@ -1,49 +0,0 @@
name: Run bw-generate-cli
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install os dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run bw-generate-cli
run: |
bw-generate-cli --target bec_widgets
git diff --exit-code

View File

@@ -1,48 +0,0 @@
name: Run Pytest with different Python versions
on: [workflow_call]
jobs:
pytest-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests

View File

@@ -1,64 +0,0 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
required: false
type: number
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
jobs:
pytest:
runs-on: ubuntu-latest
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets

View File

@@ -13,7 +13,7 @@ variables:
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
@@ -77,7 +77,7 @@ formatter:
stage: Formatter
needs: []
script:
- pip install -e ./[dev]
- pip install bec_lib[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
@@ -162,20 +162,6 @@ tests:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
matrix:
@@ -203,7 +189,7 @@ test-matrix:
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3:25.1.1-2
image: continuumio/miniconda3
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
@@ -230,7 +216,7 @@ end-2-end-conda:
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
artifacts:
when: on_failure
@@ -245,7 +231,7 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
semver:
stage: Deploy

View File

@@ -1,766 +1,6 @@
# CHANGELOG
## v2.3.0 (2025-05-09)
### Features
- **bec_connector**: Ability to change object name during runtime
([`dc151cd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dc151cdfe39f1f0507eeee307a35c1677ae4d8c5))
## v2.2.0 (2025-05-09)
### Features
- **launcher**: Add support for launching plugin widget
([`1fb680a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1fb680abb40668e72007c245f32c80112466c46e))
### Refactoring
- **launch_window**: Widget tile added
([`b9e56c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b9e56c96cbae561beb893cedb7d18e9b6a7bfc76))
## v2.1.3 (2025-05-07)
### Bug Fixes
- **bec-dispatcher**: Fix reference to boundmethods to avoid duplicated subscriptions
([`cf59d31`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf59d311132cd1a21f1893c19cc9f2a7e45101d0))
## v2.1.2 (2025-05-06)
### Bug Fixes
- **waveform**: Ignore callbacks for on_async_readback from QtSender objects that are already
destroyed; closes #497
([`64a4824`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64a48240546846fdf4541c2adf3a0a5a0829f948))
### Build System
- Remove flush-redis from ci job
([`a6c479e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a6c479e42ea2a47c45e5a323bb3072bab503ecf1))
### Refactoring
- **bec-progressbar**: Add private method for bec_progressbar, udate client file
([`37f0024`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/37f002427ad5da01164ae3b0f4983695fe61c243))
- **bec-status-box**: Add get_server_state user_access method to BECStatusBox
([`1619446`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1619446ec9839cfa1c666a3790a0c2abc449c4a8))
## v2.1.1 (2025-05-06)
### Bug Fixes
- Import add operator in client
([`55f7efc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/55f7efc4f586128dfb66fc6a8eb5d3a9f32bf61e))
### Refactoring
- Supply bec designer filename to function
([`be72c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be72c9f2708c93dab24d4383f5622e38cf1dc8a2))
## v2.1.0 (2025-05-05)
### Bug Fixes
- Ensure rpc object do not collide with protected names
([`94463af`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/94463afdba11fe2da5958a371ef49572889b8622))
### Chores
- **formatter**: Upgrade to black v25
([`452124b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/452124b528c41db14d1e34ab98db95f6f7230ad6))
### Continuous Integration
- Install dev dependencies for formatter
([`fff4af2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fff4af2489bdea0cf4f6f8db68db59fba411c25e))
### Features
- **SafeSlot**: Slot parameters can be overridden with kwarg; add option to raise
([`9387275`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/93872758517177503b1f868376a6095670131844))
### Refactoring
- **colormap_widget**: Widget is rounded
([`02563b1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02563b10f3c90bddc069446dfe4137aa5a9727cb))
### Testing
- **Dock**: Add validation for new dock creation with invalid name
([`c16b9dc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c16b9dce9ce629b794d731cd7f3282a59f8b8c59))
## v2.0.3 (2025-05-02)
### Bug Fixes
- **generate_cli**: Apply isort config
([`770dbd4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/770dbd4b63baba588871a4d4ffa77d44872d085b))
- **image_item**: Wrong user access name for rotation
([`58a0bc7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58a0bc79742e7e7578988711a9840ed6041d9a69))
### Continuous Integration
- Add job to test that the generated client is up to date
([`d22035f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d22035f8974ac51ae1b6efc0e2b3749ca0a674ff))
## v2.0.2 (2025-05-01)
### Bug Fixes
- **plot_base**: No content margin for plot_widget window
([`1b78840`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1b78840fd87ea0f156c73beeb57c6c06f685f7b1))
## v2.0.1 (2025-04-30)
### Bug Fixes
- **dock_area**: Restore state safeguard to not pass none to pyqtgraph restoreState
([`9079ddd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9079ddd7278ede7a9a12d7b39797154e83659c20))
## v2.0.0 (2025-04-29)
### Bug Fixes
- Add designer plugin for ScanMetadata
([`43e1aa9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/43e1aa9505cfa6e87b4fce1d065efb48b4111190))
- Add support for 'add_slice', add downsampling for performance improvements. add tests
([`7f7891d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7f7891dfa54588f5d902448b760f141b183a7fa1))
- Broadcast context manager to emit registry changes just once
([`a5f06c8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a5f06c8f8380156a763445a69df29ee0e62e434c))
- Bugfix in cleanup of ScatterWaveform ScatterCurve; closes #520
([`1d09107`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1d091071e1179821bb1dcd47fb97f3d0959b972f))
- Change default colormap to plasma
([`074bbbc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/074bbbc16648849bdfcfc28b2c520b0e38dd07c2))
- Create widget enum programatically
([`7726d83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7726d83b6834b8145e48e709e2f839fb0ec1b971))
- Ensure provided dock and dock_area names are valid and defaults are snake_case
([`0ac14a7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0ac14a74b851578fff668fb8c6722f990130831d))
- Expose common classes from bec_widgets package
([`28ae0d2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/28ae0d2b577d7c926ee54690898fe8e327e1229f))
- Forward parent to children
([`1fc6125`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1fc61253699425a2bf64a0f8b560f8474549b841))
- Import from qtpy instead of PySide6
([`fef07ac`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fef07ac8e12399e7e49bcd673a5fc7cbf713bc50))
- Proper cleanup of progressbar
([`8ff2063`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8ff2063bc8978c5b2a97f720d5da055e8ec08f0c))
- Rpc access enabled for certain widgets.
([`ef4a52c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef4a52cc17748f35ed627170b1025e6e028d70b8))
- Server shutdown widgets
([`75b2446`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/75b24467def65284ea6b6114b25098437e31ec95))
- Support auto_range_x/y for viewAll during measurement
([`af28e2e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af28e2e433c9b0233436da850be97cd63df90a74))
- Unique name for widgets, fix new method for docks; closes #534
([`77f9d42`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/77f9d425765061f137c997062a3bf769a939bc64))
- Warning in logpanel
([`1d7b423`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1d7b423bb307b6aae3879987776310c14380895d))
- chain a signal to the child BecLogsQueue rather than passing the signal instance in
- Wrap fetching plugin widgets in case of errors
([`ef14831`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef148317dea9c7ff985b2a3ff06ccdb37258153f))
- **auto_updates**: Fix condition to skip auto update
([`18e4ba6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/18e4ba6cfe9f67512efbd3989156de5670aab3fe))
- **bec_connector**: Add assertion to ensure BECConnector is used with a QObject; closes #475
([`1921444`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1921444e152e06c4decc790452f3c496cf8ee961))
- **bec_connector**: Add setObjectName method to update object name and broadcast if registered;
closes #472
([`064343a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/064343acf2631e4ae62b2a5e08bc08087246570c))
- **bec_connector**: Call cleanup on widgets if the parent was deleted
([`fc1cdc8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc1cdc814fc3c44a571c20986bc627935f90ff91))
- **bec_connector**: Improve cleanup handling on deleted parent to prevent errors
([`3709cdc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3709cdc86671e5219afca7a8e11bdd01f03dd30e))
- **bec_connector**: Move RPC registration into single shot method to ensure the rpc name is in sync
([`3b16c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3b16c9f5a2f7f16b23f25560b1e8fb4e42359ef0))
- **bec_queue**: Set parent for toolbar buttons
([`cdc613b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cdc613b6e7d7eac806d458515321590e9344244a))
- **becconnector**: Widgets can be flagged as root widget, skipping the BECMainWindow in CLI usage
([`061f348`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/061f3481daae6844a83c44e9caca7ed56a1bb100))
- **becconnector,widgets**: Parent_id is always fetched from the real bec widget parent; all widgets
adjusted; hardcoded parent_ids removed
([`f35f4c4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f35f4c4b295139b99a2dad9e8241f900d2565aeb))
- **BECGuiClient**: Add launch_script parameter to dock area creation
([`06a4954`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/06a4954d3da44c6805232d34e47e242b28ba7fd1))
- **cleanup**: Prevent double cleanup by tracking object destruction state
([`fde9120`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fde912005db61a60707e7181c3425a4557bdc011))
- **cli**: Add type ignore comment to generated files
([`d171255`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d1712552ffd1118845dc7121218df86ce10e8750))
- **client**: Import reduce
([`8cca510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8cca510fa1cbda00a07edbef9d36fdd74e63d201))
- **client**: Regenerated client
([`c97db6a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c97db6aaae81d08019a13c344414c16c42691654))
- **client**: Rpc API adjusted for DockArea, ImageItem and Waveform
([`6ca4aa0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ca4aa0f9b9d5ace9fb1e174219f4da5617ebbac))
- **client_utils**: Simplify RPC client instantiation in BECGuiClient
([`96b31a4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96b31a450998aca2b7ac94138b07223418d2bacd))
- **colormap_widget**: Size policy fixed
([`1cc2a98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1cc2a9848906a7013e86687976d42d4b9676b25f))
- **compact_popup**: Forward close event
([`e0f146b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e0f146beeb34367a4d3454a7012af4728d594b9b))
- **crosshair**: Adapted for 2D image
([`a85402d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a85402dde1af1d9c4a154892c46422ac3e1f22f9))
- **curve**: Fix unique names for custom curves
([`8e846d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e846d449955ded3cb8090e44ea36d26efccb80e))
- **dark-mode-button**: Fix parent passed to QObjects in various classes
([`a06f060`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a06f0600c1c9a80436f01533a82905a6f3633895))
- **designer**: Avoid touching deleted widgets during init as QtDesigner will segfault
([`4381fcc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4381fcc4c212cd03ce91f1638dc361c3315f8c45))
- **designer-plugin-generator**: Enhance super constructor validation for new style classes
([`6318b2d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6318b2d822be0ded561a1afd0d485158614e2406))
- **device_input_base**: Removed enums from Pydantic models to make them serialisable
([`43b747e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/43b747ec8a761530d78b26650b0ec2ee4581ffaf))
- **dock_area**: Close BECMainWindow if dock area is central widget
([`e725de3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e725de3c4504d43fbcad25d69c5cb8cbe7a70867))
- **docs**: Update copyright year to be dynamic
([`f2d5b57`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f2d5b57e86d0c9d690b8d9f988035427608f0b4c))
- **entry_validator**: Validator reports list of signal if user chooses the wrong one
([`da05877`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/da05877dd04fa618cdb45268cb62df602a5e808f))
- **image**: Imageitem remove adjusted to disconnect and remove current displayed image
([`98f159b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/98f159b25f6bf7e1f2dd76726d7ab66a0baf88de))
- **launch_window**: Redesign
([`7e65d4f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7e65d4f2d6d840d3895e023f5cd090a56ea6e5f3))
- **launch_window**: Return None when cancelling the ui file launcher
([`b3dbe92`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3dbe922dea2cea9190d1583bd6b69f1a45d6b90))
- **launch_window**: Update LaunchTile icon to use new UI loader tile image
([`3cd6e05`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3cd6e05b2478654210049ca8e1756ad592f1da81))
- **launcher**: Hide launcher when launcher is closed even though it is not the last widget
([`6e7920c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e7920c119824650006e7357ca2f4ff95d413e13))
- **lmfit_dialog_vertical**: Vertical sizePolicy fixed
([`584b945`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/584b94500565a33e5daed86b7552ec54f1135cf6))
- **main_window**: Connected to theme change
([`11feeff`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11feeff37ce0b02fcbc8e506c67c14e1fc5e0cb6))
- **main_window**: Show app id only when connected to redis
([`be72268`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be722683a7cc7b215c572f9c2e996839b010b64e))
- **moduar-toolbar**: Fix cleanup of modular toolbar and dock_area
([`c70cd9d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c70cd9d6e8f7ea9d5f81b10ac437cdcc9ee900e9))
- **motor_map**: Limit map creating optimized
([`9f2a083`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9f2a083abbcfb465ebea9acee8263dcc9a6da5d9))
- **plot_base**: Ability to set y label suffix
([`890b501`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/890b50115fef845c2a77242fdb05863d2eec4a00))
- **plot_base**: Aspect ratio removed from the PlotBase
([`19d8aeb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/19d8aeb16249a1093cfec124d0ebdf6af11d94a8))
- **plot_base**: Axis setting filter for relevant properties
([`0204d9c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0204d9c86f9665dcefdcbe7f49ac23918d74dd66))
- **plot_base**: Do not enable inner axes when label is changed
([`98eda03`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/98eda03f4d6d449605d5559a1db44c900d93cb79))
- **plot_base**: Enable popup property fixed
([`30db183`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30db18367e9c6d6375fda970a1bb255d966cba5a))
- **plot_base**: Fix cleanup of popups if popups are still open when PlotBase is closed
([`39cf4dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/39cf4ddd5a033ee7f589d508f765669186e776bc))
- **plot_base**: Improved handling of matplotlib exporter errors
([`4f9514f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4f9514fbd1ff0059248d3b7b5b4fcd85c3eb9c72))
- **plot_base**: Inner and outer axis setting in popup mode
([`055b968`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/055b96818aa69d66119caee9a3e8c24575ce60b4))
- **plot_base**: Update mouse mode state on mode change
([`fc24c8b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc24c8b3a5f9cd55fb3d49f753b53a65a2a0fa26))
- **plot_framework**: All widgets, popups and side menus cleanups adjusted
([`337a332`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/337a332ed123f99729b8cf6869f7fe4b056c2b16))
- **plot_indicators**: Cleanup adjusted
([`4865341`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48653410101a2a38d5067fbfca7712d255d89625))
- **plot_indicators**: Plot indicators added to the PlotBase
([`42e3b9c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/42e3b9c13786e67220874a1275a3d9ee9515541a))
- **positioner-indicator**: Fix property setters for position indicator
([`1910993`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1910993b2b3d30ecb8e4977b4a362f46adae3c75))
- **progress-ring-bar**: Fix parent inheritance and cleanup of ring objects; closes #496
([`b460ea9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b460ea9955318879ddfe4f9ae963249ba342bbb5))
- **ring-progress-bar**: Fix bug in disconnect slot of rings, enable 'scan' mode as default for init
with first ring
([`7c303d0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7c303d01294493a55fd26db8c1475e8c58b3e492))
- **ring_progress_bar**: Replaced hard-coded endpoints by MessageEndpoints
([`e4e9feb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e4e9febc98268a4b6b9774b253419e88ea044811))
- **round_frame**: Orientation can be vertical
([`c1bbb16`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1bbb16dad481c628e7680180d7250ba8a560c46))
- **round_frame**: Roundframe removed from BECWidget inheritance
([`b58a098`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b58a098ed4afbe62721fd2bf8497f363deecbfa6))
- **rpc**: Call close on container widget if needed
([`a13de45`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a13de45131309771c1438407f3733a8c0897d495))
- **rpc-base**: Deprecate widget_name in favor of object_name; closes #499
([`86647b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/86647b9b7e2fa111105ae483808883a624fa4cd6))
- **rpc_base**: Ensure message wait event is set after processing RPC response
([`4dc59aa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4dc59aa5e9b15e5ec40401e80e7965acd88e2fce))
- **rpc_base**: Timeout run_rpc 3s
([`8558b46`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8558b46114760a9434eaa827f81d5fd9d047112f))
- **rpc_register**: _lock and _skip_broad_cast moved to instance attributes
([`8d17f7e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8d17f7e32f81894294d7da472268e8d9eb3bb74b))
- **rpc_register**: Change add_rpc parameter type to BECConnector and add object_is_registered
method
([`82b8265`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/82b82659b7919b15d629375866302624b5b6e457))
- **rpc_register**: Lock changed to RLock
([`6c90ca3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6c90ca31078d97124a3ad535ffe83da138558d67))
- **rpc_server**: Broadcasted data check
([`c36852b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c36852b2ef762cdae3fde569bbd0d5f2f6f2725b))
- **rpc_server**: Enhance serialization logic for BECConnector objects and fix return types
([`125afc8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/125afc89073b4fc69a3f42650b3d4f6fa6ccaa47))
- **rpc_server**: Update _serialize_bec_connector to include wait parameter for registration check
([`d6fccd1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d6fccd10f5d600ea67cf7b2a5ebb42295d15cdfe))
- **RPCReference**: Setattr added
([`a2128ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a2128ad8d688995551c5e26974396fd0588b6804))
- **scan_control**: Restore scan parameters always regenerate the arg box, preventing infinite loop
([`1f2db92`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1f2db927f50f4f30d43ebe52e39118c7d79994d4))
- **scan_matadata**: Parent passing
([`4eaadd1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4eaadd1545885b111fce3f8cab527a77b8633ff3))
- **scatter_waveform,waveform**: Added QTimer to fetch the last data points after 500ms
([`e6795dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6795dd87ccd93cfd53e22cd94d71bffe1ef54dd))
- **serialization**: Add serialization for qpointf
([`3ddfeaa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3ddfeaa49fd4a7fdbff7cae47b90c25720f6dca0))
- **server**: Becdockarea type added
([`4a74891`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a74891184f112751258866b6bc9d800dbc5ed05))
- **server**: Remove window.hide() since widgets will be teared down on kill_server before siginit
signals is sent
([`58b0c7d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58b0c7ddc1d0b85b35e7e18434c0b83aac01a735))
- **server**: Turn_off_the_lights cleanup fixed for parent_id widgets
([`20a86ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/20a86ad325d36aa5aec73aeda7ff43ea9cc6c1f7))
- **setting_widget**: Added parent kwarg into all settings widgets in plotting framework
([`94c2e2d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/94c2e2db6518402207b2a1077bb16403a8e61cee))
- **side_panel**: Side panel menu can be initialized without a title
([`112eed6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/112eed694c0ef6eb80ec7a7cfdfbaacf732d5b9f))
- **toolbar**: Update action check handling logic for SwitchableToolBarAction
([`ac08bdf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac08bdfab2162ac8fd103e60779a76d36e9a3765))
- **type hints**: Add future import to prevent sphinx from crashing
([`aff5a51`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aff5a51f4c059ce21ec72cefc263f37df2491480))
- **waveform**: Dap curve flickering
([`b03d2ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b03d2eaeed4263846c470bc45eba9208ced2370b))
- **waveform**: Error where scan history is empty
([`288ea4d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/288ea4dbbde6d5f770c37f4daf377da9ec8fe729))
- **waveform**: Fix dap curve categorization logic
([`b91f1fe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b91f1fe4879e43e71a1be49ce5a206efbae19315))
- **waveform**: Legend is correctly updated when changed from curve dialog
([`c2d2c48`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c2d2c484cd1f133f45fe7147616c22c0b5fd5611))
- **waveform**: Signals for x device can be defined from gui
([`39164fe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/39164feb18f9e996d97814f58157892e8db816ae))
- **waveform, rpc_reference**: __getitem__ removed form waveform and rpc_reference
([`3a82c95`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3a82c95f60cb2b7f0b29a1ea5cdcbfa5bf602af8))
- **website-widget**: Add super().cleanup() in website widget
([`8fbd54c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8fbd54c3aa864623e42b39a1ebf92ba098ba437d))
- **widgets**: Becconnector resolves hierarchy including objectName, parent, parent_id upon init;
all widgets adjusted
([`a1bec75`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a1bec7511549277da231928d989b16ecad0eed1b))
### Build System
- Pyside6 capped to 6.9
([`9dabf2c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9dabf2c66c8023194964b9ad308e06197471f89f))
- **bec_lib**: Raised required version to 3.28.1
([`a5f1f47`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a5f1f4781ed9787148053d71e0d12fefe42e142a))
- **dependencies**: Update min bec_lib version to 3.29
([`eb0323b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/eb0323b989e96e89d2eb1ff7b648edb43f5fe198))
### Continuous Integration
- **e2e**: E2e tests are saving logs
([`d4106c5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d4106c548e2373463a48268fd991ded7f554e3a6))
### Documentation
- Add docs on widget plugins
([`52a9f29`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/52a9f29bdcb20a9339a8970508bc0a93ba8bef5f))
- Add missing class doc strings for rpc-enabled widgets
([`cfc8272`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cfc8272ac288541d1e20c0840bd2ce6fa930897c))
- Better document logpanel code
([`d2c9075`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2c90757c21e040940a378325cad75c4d94470f9))
- Grammar improvement
([`1fe052e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1fe052e9da9e44bf9872db0d42218843a8e6d275))
- Remove BECFigure
([`75cc45d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/75cc45d767970e985c566fd4aeccd4394f48dfa3))
- Remove BECFigure from docs, fix wrong api for docs of plotting widgets
([`a1c859c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a1c859c7434357e6fb82f0912314c203fb73e890))
- Replaces instances of QtDesigner with BEC Designer for improved clarity
([`60852e2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/60852e228f80f0d2e74813f82bd30f1ba83ff154))
- Review quick_start
([`4acf5be`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4acf5befb1dbcc69e8cc7da70ebf5663b9ec15f2))
- Update docs for v2
([`25bd905`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/25bd905cef987a713e24aca178c04aef1ab59656))
- Update docs for various widgets
([`b6695b4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b6695b45d076dbf3896e94eedcf73d542022d764))
- Update quick_start
([`afc818b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/afc818bf7d17f42703c3cebafd2b292b8444647a))
- Updated docs for v2 ([#531](https://gitlab.psi.ch/bec/bec_widgets/-/merge_requests/531),
[`b4af2cc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b4af2cc77aa0013f4547cd98345b0c77abb7101b))
- **auto_updates**: Update documentation for auto updates functionality and add launcher image
([`6630ba1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6630ba1c421e566bf86ac38701a86eff624395d2))
- **lmfit**: Fix links
([`5e4965f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5e4965fe1f88d18fcc7e6875777ff3eb01ab08ec))
- **plot_base**: Update docstrings for properties and setters
([`b085ef6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b085ef6e730d529149bcb696b1ad4cd9c5220a83))
- **position-indicator**: Update docs for positioner indicator
([`2f0d213`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2f0d213e32fd662bfffd4df73b9281fa30cef6e3))
### Features
- Add loader/helper for widget plugins
([`ca2bb4f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca2bb4f9b42ebaac2fc544d3da36267d93e9903d))
- Add rpc broadcast
([`2ba9b4c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2ba9b4cb236a2182261dfb88398d5ece733ba393))
- Add support for auto updates
([`2511056`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2511056557daf0b5dd78d3e85ac4befb8bf8c316))
- Delete bec_app
([`8e64b65`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8e64b65c2d3b8a8f3c6e5376e694369b41733da4))
- Deprecated and delete alignment_1d gui
([`27ea92d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/27ea92d120cc8ef01fff10341ce0954b4f7fed5d))
- Namespace update for gui, dock_area and docks.
([`ac3c5a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac3c5a38e449c2c3e4a1c61d5f9a59acfbf0cab5))
- **auto_update**: Add GUI highlight management for auto updates status
([`5f272a6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f272a66a4d4f65273d7b2a6709336cd3582d695))
- **auto_updates**: Enforce rpc widget class for subclasses of auto updates
([`778230b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/778230b5edf5d24df8a10c78c90ea065510e8344))
- **image**: New Image widget based on new PlotBase
([`cb39ff3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cb39ff3fbde99f4e4bed49dee8a5e5987d257b23))
- **launch_window**: Add custom UI file launching functionality and UI tile
([`3089ca1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3089ca15ec4a8c110d11c57aff2da42f4af5bd08))
- **launch_window**: Add user access permissions
([`8efa93d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8efa93d2d2c5e6c28008e1bbde89e5cc8a01d139))
- **launch_window**: Enhance auto update functionality with selector and dynamic loading
([`2965323`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/29653239c5cf43313224cc5123d066fcba4b831b))
- **launcher**: Add option for launching with auto updates
([`20a1c5d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/20a1c5ddb3cd0763ce69bba5a893f54c56678706))
- **main_window**: Add launcher menu and functionality to show launcher
([`55baa84`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/55baa84eb6723b30b407092bc36f826b826cc934))
- **motor_map**: New MotorMap widget based on PlotBase
([`fec26d7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fec26d793e14965a719a4d038838418b9a7603bb))
- **multi_waveform**: Multi-waveform widget based on new PlotBase
([`77f9616`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/77f96160ab348c1a65ceb55986ea4ea75f8be04a))
- **plugin_utils**: Add functionality to retrieve auto update classes from plugins
([`c434af9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c434af9b92d68d08da87112ef424738e5e42ae6e))
- **positioner_box**: Add units QLabel to device UI components and update visibility logic
([`f653fc5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f653fc5f7ebf8ad5297facd739a8a49ea0a06c95))
- **scatter_waveform**: Scatter waveform widget based on new Plotbase
([`95fcf01`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/95fcf016c32c52330acfd5900a3996c99c4ee01f))
- **server,launcher**: Rpc server separated with the launcher window introduced
([`5f27a90`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5f27a9098903ffd8ec27c1b45565f1c113892cca))
- **slot**: Add 'verify_sender' argument to SafeSlot for sender verification
([`8eef425`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8eef4253b0507f60f50c06ed48b59a1b19b29644))
- **ui_launch_window**: Add UILaunchWindow class
([`45cd82e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/45cd82e6354c72e1e35cd6366aa7aad93f8b12ca))
- **waveform**: New Waveform widget based on NextGen PlotBase
([`4bec181`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4bec181f3aff34d9de7d3f9ec012b641c125a661))
- **widget_io**: Added handler for Sliders
([`1a0097e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1a0097e02728b6470217d3a574260f376776d81f))
### Refactoring
- Add fallback to 'index' plotting in case of missmatch in length
([`515d7ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/515d7ad05584086a8e8ac626b476d629e27aacf3))
- Add pragma no cover to various TYPE_CHECKING
([`f88dfc8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f88dfc8f1bbc0819736a4f32bf21682366fd3437))
- Add support to plot against x_data
([`0e276d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0e276d4c09cddb459688aecf28684a963d8f6613))
- Add template for debugging the cli generator
([`f89e74b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f89e74b199d007cf47f355a1c5e1f582daeea90a))
- Autoupdate disabled
([`4e29291`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e29291b3a0891657a8d2011bcaf1d6e65de125a))
- Cleanup MR
([`0b00cd2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0b00cd24fd43dbc87c81f7dbfae816343f7da4c4))
- Cleanup rpc reference tracking, fix appquit, fix namespace updates edge cases
([`7ba93ce`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7ba93ce934cc644ad6340f141a6a0888bd1d3d98))
- Cleanup, fix tests and _top_level dict/windows
([`5872253`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58722531232b2290f9fd974bae24877c9d5451f4))
- Fix cleanup bug for BECConnector items, renamed _registry_state to _server_registry
([`be83c7d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be83c7d5f4bc04d110734b491727dc60d8dd61ef))
- Fix cleanup for various widgets, including RoundedFrame
([`d05179a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d05179a519d6419c9631ffdf4fa6aa262966c2ed))
- Improve plotting behaviour from history
([`ed2d958`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ed2d958de62223cd796c869b6c8b9b75170e66f5))
- Rearrange base of metadata forms for generic use
([`d04770f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d04770fe913474ec9d4e06b056c85e720d1470c4))
- Set downsampling to auto=True, method 'peak', activate clipToView for (Async)-Curves and fix
ViewAll hook from pg.view_box menu
([`25820a1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/25820a1cdec2cff99ab0d6085aece0e3e7dd9092))
- Tidy client generation and add options
([`b492591`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b4925918f7acf31e40971814639be8a6c55d46df))
- **assets**: New icon for ui loader
([`e5b5322`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5b532274ede281456a14b02a99855302603490a))
- **auto_update**: Auto_update changed to be BECMainWindow; removed auto update logic from
BECDockArea
([`56c2827`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56c282714037f733bcfd8a659f34baadcd1aa223))
- **auto_updates**: Move cleanup method from user section to internal section
([`ac9224e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ac9224e5f2d3edcb5b1cc1cbc1a8583f81d0b912))
- **bec_connector**: Replace pyqtSlot with SafeSlot for consistency
([`9d6d0b4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9d6d0b406a812e08ca8417415b5def98b40bdf92))
- **bec_figure**: Becfigure removed
([`f76d931`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f76d9319bd13bb52b1ae2524c1c5e44a167cc330))
- **client_utils**: Remove unused auto update attributes from BECGuiClient
([`b7795b4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b7795b4d0ae21641bead0f1f1541f920ae95702a))
- **image_widget**: Old BECImageWidget removed
([`de10609`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/de10609b3c714b80a14bf6940e86763d0779402b))
- **launch_window**: Remove cleanup method
([`9a940bb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9a940bb8d58f37d1fc24ce4fdb38282d02349efb))
- **launcher,main_window**: Launcher window moved to inherit from BECMainWindow
([`99383b7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99383b77150ca7c74c19c899a0e6a7879b770376))
- **motor_map_widget**: Becmotormapwidget removed
([`f878e87`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f878e87ad545e0fe68292030d9f06dee693e0da2))
- **multi_waveform_widget**: Becmultiwaveformwidget removed
([`7c31bbd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7c31bbd9c2e0230e54f4dca0f1e5c4d2cd6e7674))
- **plots**: Plot_next_gen module renamed to plots
([`9fb9a1c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9fb9a1cfd2a94efd5e2a9fcbaa05d65c7b7105ee))
- **plots**: Waveform and image rpc api review
([`a3de1f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a3de1f0a31dfb9048493fb61983167960577fb97))
- **rpc_reference**: Refactor rpc reference tracking
([`bd5e251`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bd5e251ee9396633f419732e43411821726250aa))
- **rpc_server**: Add type hint for _get_becwidget_ancestor method parameter; minor cleanup of
imports
([`cb91ebc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cb91ebc0c34ae29b6b293996199f4624d36a3cc0))
- **rpc_server**: Add type hints and docstrings for heartbeat and registry update methods
([`08168f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/08168f28d3c5375b9ace9df0d7aa31e33adb97e9))
- **rpc_server**: Cli_server renamed to rpc_server
([`6082e7a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6082e7a6907c2fe15e4e5ebca857fbf8f222d192))
- **tests**: Create dummy scan item moved to client_mocks.py
([`0dd9617`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0dd9617e6e5ea756edc344c324451480a62bdae2))
- **ui_loader**: Remove unnecessary parent_id handling
([`d60cf6c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d60cf6c843ecc135a6065d1e913f9f6abb1a483d))
- **ui_loader**: Remove unused import
([`a6ce312`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a6ce312f7c60c2babcc37127f7c69d54c1b32573))
- **utils**: Qt_utils moved to utils
([`be552d3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be552d3ece97e7f472c4534b4af8438b95c518aa))
- **waveform_widget**: Removed and replaced by Waveform
([`96cff49`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96cff49cd4453fa70d8802653d5afe62d71c6b2a))
### Testing
- Add function scoped rpc_widgets e2e test; closes #510
([`36dc174`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/36dc174bfedf212532658b84f8ab64971863d292))
- Add IPython client GUI object test module with tab completion
([`e3d0d55`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e3d0d5566c50f6a80ba861d4e3e0789f17785a46))
- Add tests for name creation of custom curves, and object name handling
([`99d7623`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99d76236cac63042f0d7d1db580dde8aa7cfd214))
- Disable test_bec_dock_rpc_e2e module, issue to fix this created #450
([`17f2dda`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17f2dda977025bc422e26289293d3fcbd224a6f6))
- Fix rpc widgets e2e test
([`113938e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/113938e71a6dacba37164069e2c795cc9db168d4))
- Fix tests for launcher close / hide behavior
([`23fee22`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/23fee22ef8f2c22f191dfc1da57b921484ede6cd))
- Fix tests for namespace updates
([`f3d3c94`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f3d3c9425d3ed619b978427cea782137beedfb59))
- Qapp must shutdown cli server before checking for leaked QTimer
([`d066051`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d06605122e5c2e225650b44ebfc047daa5aa6f55))
- **bec_connector**: Becconnector requires a QObject
([`23bdd95`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/23bdd95d8c6311d989cb3b807921e3fb2a3d62a0))
- **device_signal_input**: Fix init of device input widget
([`31c3b64`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/31c3b64d7b157e5e26d44e5288afabef343c5e13))
- **e2e**: E2e tests adjusted for new plotting framework
([`378398a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/378398a29b34e43f0cca0a49b08adfcb144e4777))
- **generate_cli**: Fix reference output
([`a8adb06`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a8adb064f5011e1708ee2dc0090326f533407260))
- **launch_window**: Add test for launching UI file that raises ValueError for QMainWindow
([`33a8a76`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/33a8a767f31a57bcfda624d503ed39e0e4578dcb))
- **launch_window**: Add unit tests for LaunchWindow initialization and custom UI file launching
([`d5e422c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d5e422c7fc0e169c35d7f206937e8c7902fbf123))
- **launch_window**: Tests for default and plugin auto updates
([`e10f5ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e10f5ec088c6937beb26ec468f510a209c7cc782))
- **plot_base**: Test for plot base re-enabled
([`b51d637`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b51d637c5ff3418420801cd9b457fc073fa98adc))
- **plot_indicators**: Tests adapted to not be dependent on BECWaveformWidget
([`360fe4c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/360fe4c9c3b5c3c1f26e97cb795aef8f4aba3b46))
- **setting_dialog**: Test that settings reject calls cleanup
([`8914f1d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8914f1d50600cab588a6cbecb08d85bfd1a715a1))
- **unit_tests**: Unit tests adjusted to use a modern plotting framework instead of BECFigure
([`6ade934`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ade93435632fa66fb012d92f9b8b548d96e718f))
## v1.25.1 (2025-03-24)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2025, Paul Scherrer Institute
Copyright (c) 2023, bec
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@@ -1,16 +1,5 @@
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,9 @@
from __future__ import annotations
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
_dock_area = BECDockArea(object_name=object_name)
return _dock_area

View File

@@ -2,10 +2,10 @@ from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
@@ -21,10 +21,8 @@ from qtpy.QtWidgets import (
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
@@ -37,8 +35,6 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -145,13 +141,12 @@ class LaunchWindow(BECMainWindow):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
@@ -161,104 +156,53 @@ class LaunchWindow(BECMainWindow):
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.register_tile(
name="dock_area",
self.tile_dock_area = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
self.tile_auto_update = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
)
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.register_tile(
name="custom_ui_file",
self.tile_ui_file = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
)
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# plugin widgets
self.available_widgets: dict[str, BECWidget] = get_all_plugin_widgets()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
# Add tiles to the main layout
self.central_widget.layout.addWidget(self.tile_dock_area)
self.central_widget.layout.addWidget(self.tile_auto_update)
self.central_widget.layout.addWidget(self.tile_ui_file)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
tile.setFixedSize(*self.TILE_SIZE)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
tile.selector.addItems(selector_items)
self.central_widget.layout.addWidget(tile)
self.tiles[name] = tile
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
)
def launch(
self,
@@ -266,7 +210,7 @@ class LaunchWindow(BECMainWindow):
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
**kwargs,
) -> QWidget | None:
) -> QWidget:
"""Launch the specified script. If the launch script creates a QWidget, it will be
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
as a separate window.
@@ -287,8 +231,6 @@ class LaunchWindow(BECMainWindow):
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
@@ -300,20 +242,12 @@ class LaunchWindow(BECMainWindow):
if launch_script == "custom_ui_file":
ui_file = kwargs.pop("ui_file", None)
if not ui_file:
return None
return self._launch_custom_ui_file(ui_file)
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
@@ -331,7 +265,6 @@ class LaunchWindow(BECMainWindow):
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
@@ -341,8 +274,6 @@ class LaunchWindow(BECMainWindow):
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
@@ -380,28 +311,11 @@ class LaunchWindow(BECMainWindow):
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles.values():
for tile in self.tiles:
tile.apply_theme(theme)
super().apply_theme(theme)
@@ -410,25 +324,14 @@ class LaunchWindow(BECMainWindow):
"""
Open the auto update window.
"""
if self.tiles["auto_update"].selector is None:
if self.tile_auto_update.selector is None:
auto_update = None
else:
auto_update = self.tiles["auto_update"].selector.currentText()
auto_update = self.tile_auto_update.selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_widget(self):
"""
Open a widget from the available widgets.
"""
if self.tiles["widget"].selector is None:
return
widget = self.tiles["widget"].selector.currentText()
if widget not in self.available_widgets:
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""
@@ -468,45 +371,6 @@ class LaunchWindow(BECMainWindow):
super().showEvent(event)
self.setFixedSize(self.size())
def _launcher_is_last_widget(self, connections: dict) -> bool:
"""
Check if the launcher is the last widget in the application.
"""
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
if self._launcher_is_last_widget(connections):
self.show()
self.activateWindow()
self.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True) # type: ignore
return
self.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False) # type: ignore
def closeEvent(self, event):
"""
Close the launcher window.
"""
connections = self.register.list_all_connections()
if self._launcher_is_last_widget(connections):
event.accept()
return
event.ignore()
self.hide()
if __name__ == "__main__":
import sys

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -6,8 +6,6 @@ from __future__ import annotations
import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
from bec_lib.logger import bec_logger
@@ -27,31 +25,22 @@ class _WidgetsEnumType(str, enum.Enum):
_Widgets = {
"AbortButton": "AbortButton",
"BECDockArea": "BECDockArea",
"BECProgressBar": "BECProgressBar",
"BECQueue": "BECQueue",
"BECStatusBox": "BECStatusBox",
"DapComboBox": "DapComboBox",
"DarkModeButton": "DarkModeButton",
"DeviceBrowser": "DeviceBrowser",
"DeviceComboBox": "DeviceComboBox",
"DeviceLineEdit": "DeviceLineEdit",
"Image": "Image",
"LogPanel": "LogPanel",
"Minesweeper": "Minesweeper",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PositionIndicator": "PositionIndicator",
"PositionerBox": "PositionerBox",
"PositionerBox2D": "PositionerBox2D",
"PositionerControlLine": "PositionerControlLine",
"ResetButton": "ResetButton",
"ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar",
"ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform",
"StopButton": "StopButton",
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
@@ -87,16 +76,6 @@ except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
class AbortButton(RPCBase):
"""A button that abort the scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class AutoUpdates(RPCBase):
@property
@rpc_call
@@ -470,12 +449,6 @@ class BECProgressBar(RPCBase):
>>> progressbar.label_template = "$value / $percentage %"
"""
@rpc_call
def _get_label(self) -> str:
"""
Return the label text. mostly used for testing rpc.
"""
class BECQueue(RPCBase):
"""Widget to display the BEC queue."""
@@ -490,12 +463,6 @@ class BECQueue(RPCBase):
class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services."""
@rpc_call
def get_server_state(self) -> "str":
"""
Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server
"""
@rpc_call
def remove(self):
"""
@@ -698,14 +665,6 @@ class DapComboBox(RPCBase):
"""
class DarkModeButton(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
@@ -964,6 +923,48 @@ class Image(RPCBase):
Set auto range for the y-axis.
"""
@property
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@x_log.setter
@rpc_call
def x_log(self) -> "bool":
"""
Set X-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@y_log.setter
@rpc_call
def y_log(self) -> "bool":
"""
Set Y-axis to log scale if True, linear if False.
"""
@property
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@legend_label_size.setter
@rpc_call
def legend_label_size(self) -> "int":
"""
The font size of the legend font.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -1158,16 +1159,16 @@ class Image(RPCBase):
@property
@rpc_call
def num_rotation_90(self) -> "int":
def rotation(self) -> "int":
"""
The number of 90° rotations to apply counterclockwise.
The number of 90° rotations to apply.
"""
@num_rotation_90.setter
@rotation.setter
@rpc_call
def num_rotation_90(self) -> "int":
def rotation(self) -> "int":
"""
The number of 90° rotations to apply counterclockwise.
The number of 90° rotations to apply.
"""
@property
@@ -1330,14 +1331,14 @@ class ImageItem(RPCBase):
@property
@rpc_call
def num_rotation_90(self) -> "Optional[int]":
def rotation(self) -> "Optional[int]":
"""
Get or set the number of 90° rotations to apply.
"""
@num_rotation_90.setter
@rotation.setter
@rpc_call
def num_rotation_90(self) -> "Optional[int]":
def rotation(self) -> "Optional[int]":
"""
Get or set the number of 90° rotations to apply.
"""
@@ -1387,9 +1388,6 @@ class LogPanel(RPCBase):
"""
class Minesweeper(RPCBase): ...
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@@ -2168,7 +2166,7 @@ class MultiWaveform(RPCBase):
"""
@rpc_call
def plot(self, monitor: "str", color_palette: "str | None" = "plasma"):
def plot(self, monitor: "str", color_palette: "str | None" = "magma"):
"""
Create a plot for the given monitor.
Args:
@@ -2198,10 +2196,7 @@ class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
"""
Set the position of the indicator
Args:
position: The new position of the indicator
None
"""
@rpc_call
@@ -2221,13 +2216,6 @@ class PositionIndicator(RPCBase):
Property to determine the orientation of the position indicator
"""
@vertical.setter
@rpc_call
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
@property
@rpc_call
def indicator_width(self):
@@ -2235,13 +2223,6 @@ class PositionIndicator(RPCBase):
Property to get the width of the indicator
"""
@indicator_width.setter
@rpc_call
def indicator_width(self):
"""
Property to get the width of the indicator
"""
@property
@rpc_call
def rounded_corners(self):
@@ -2249,61 +2230,6 @@ class PositionIndicator(RPCBase):
Property to get the rounded corners of the position indicator
"""
@rounded_corners.setter
@rpc_call
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form"""
@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 PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form"""
@rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def set_positioner_ver(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerControlLine(RPCBase):
"""A widget that controls a single device."""
@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 PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form"""
@@ -2317,26 +2243,6 @@ class PositionerGroup(RPCBase):
"""
class ResetButton(RPCBase):
"""A button that resets the scan queue."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class ResumeButton(RPCBase):
"""A button that continue scan queue."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class Ring(RPCBase):
@rpc_call
def _get_all_rpc(self) -> "dict":
@@ -2465,9 +2371,9 @@ class RingProgressBar(RPCBase):
@property
@rpc_call
def rings(self) -> "list[Ring]":
def rings(self):
"""
Returns a list of all rings in the progress bar.
None
"""
@rpc_call
@@ -2941,7 +2847,7 @@ class ScatterWaveform(RPCBase):
x_entry: "None | str" = None,
y_entry: "None | str" = None,
z_entry: "None | str" = None,
color_map: "str | None" = "plasma",
color_map: "str | None" = "magma",
label: "str | None" = None,
validate_bec: "bool" = True,
) -> "ScatterCurve":
@@ -2981,16 +2887,6 @@ class ScatterWaveform(RPCBase):
"""
class StopButton(RPCBase):
"""A button that stops the current scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format"""
@@ -3335,20 +3231,6 @@ class Waveform(RPCBase):
None
"""
@property
@rpc_call
def x_entry(self) -> "str | None":
"""
The x signal name.
"""
@x_entry.setter
@rpc_call
def x_entry(self) -> "str | None":
"""
The x signal name.
"""
@property
@rpc_call
def color_palette(self) -> "str":

View File

@@ -20,7 +20,6 @@ from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
@@ -32,8 +31,7 @@ logger = bec_logger.logger
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
str | bool | dict,
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
]
# pylint: disable=redefined-outer-scope
@@ -216,7 +214,6 @@ class BECGuiClient(RPCBase):
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
####################
#### Client API ####
@@ -278,8 +275,6 @@ class BECGuiClient(RPCBase):
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
**kwargs,
) -> client.BECDockArea:
"""Create a new top-level dock area.
@@ -295,11 +290,11 @@ class BECGuiClient(RPCBase):
if wait:
with wait_for_server(self):
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
"launch", "dock_area", name, geometry
) # pylint: disable=protected-access
return widget
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget

View File

@@ -40,8 +40,6 @@ class ClientGenerator:
"""import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base
@@ -223,18 +221,18 @@ class {class_name}(RPCBase):"""
# Combine header and content, then format with black
full_content = self.header + "\n" + self.content
try:
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
except black.NothingChanged:
formatted_content = full_content
config = isort.Config(
isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=False,
include_trailing_comma=True,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content, config=config)
formatted_content = isort.code(formatted_content)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@@ -317,7 +315,4 @@ def main():
if __name__ == "__main__": # pragma: no cover
import sys
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
main()

View File

@@ -130,7 +130,6 @@ class RPCBase:
config: dict | None = None,
object_name: str | None = None,
parent=None,
**kwargs,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
@@ -145,21 +144,21 @@ class RPCBase:
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.object_name}>"
return f"<{qualname} with name: {self.widget_name}>"
def remove(self):
"""
Remove the widget.
"""
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
if proxy := obj.get("container_proxy"):
assert isinstance(proxy, str)
self._run_rpc("remove", gui_id=proxy)
return
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self.object_name
@property
def _root(self) -> BECGuiClient:
"""
@@ -172,15 +171,7 @@ class RPCBase:
parent = parent._parent
return parent # type: ignore
def _run_rpc(
self,
method,
*args,
wait_for_rpc_response=True,
timeout=5,
gui_id: str | None = None,
**kwargs,
) -> Any:
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=5, **kwargs) -> Any:
"""
Run the RPC call.
@@ -188,8 +179,6 @@ class RPCBase:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
@@ -198,7 +187,7 @@ class RPCBase:
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access

View File

@@ -28,6 +28,7 @@ class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
self.encoding = "utf8"
def write(self, buffer):
self._buffer.append(buffer)
@@ -83,6 +84,29 @@ class GUIServer:
service_config = ServiceConfig()
return service_config
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
self.launcher_window = cast(LaunchWindow, self.launcher_window)
remaining_connections = [
connection
for connection in connections.values()
if connection.parent_id != self.launcher_window.gui_id
]
if len(remaining_connections) <= 1:
self.launcher_window.show()
self.launcher_window.activateWindow()
self.launcher_window.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True)
else:
self.launcher_window.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False)
def _run(self):
"""
Run the GUI server.
@@ -102,6 +126,10 @@ class GUIServer:
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
register = RPCRegister()
register.callbacks.append(self._turn_off_the_lights)
register.broadcast()
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.

View File

@@ -43,22 +43,22 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"pg": pg,
"wh": wh,
"dock": self.dock,
# "im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
# "btn2": self.btn2,
# "btn3": self.btn3,
# "btn4": self.btn4,
# "btn5": self.btn5,
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
"im": self.im,
"mi": self.mi,
"mm": self.mm,
"lm": self.lm,
"btn1": self.btn1,
"btn2": self.btn2,
"btn3": self.btn3,
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wf": self.wf,
"scatter": self.scatter,
"scatter_mi": self.scatter,
"mwf": self.mwf,
}
)
@@ -77,76 +77,76 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
# self.lm = LayoutManagerWidget()
# third_tab_layout.addWidget(self.lm)
# tab_widget.addTab(third_tab, "Layout Manager Widget")
#
# fourth_tab = QWidget()
# fourth_tab_layout = QVBoxLayout(fourth_tab)
# self.pb = PlotBase()
# self.pi = self.pb.plot_item
# fourth_tab_layout.addWidget(self.pb)
# tab_widget.addTab(fourth_tab, "PlotBase")
#
# tab_widget.setCurrentIndex(3)
#
third_tab = QWidget()
third_tab_layout = QVBoxLayout(third_tab)
self.lm = LayoutManagerWidget()
third_tab_layout.addWidget(self.lm)
tab_widget.addTab(third_tab, "Layout Manager Widget")
fourth_tab = QWidget()
fourth_tab_layout = QVBoxLayout(fourth_tab)
self.pb = PlotBase()
self.pi = self.pb.plot_item
fourth_tab_layout.addWidget(self.pb)
tab_widget.addTab(fourth_tab, "PlotBase")
tab_widget.setCurrentIndex(3)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
# self.btn2 = QPushButton("Button 2")
# self.btn3 = QPushButton("Button 3")
# self.btn4 = QPushButton("Button 4")
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
# sixth_tab = QWidget()
# sixth_tab_layout = QVBoxLayout(sixth_tab)
# self.im = Image()
# self.mi = self.im.main_image
# sixth_tab_layout.addWidget(self.im)
# tab_widget.addTab(sixth_tab, "Image Next Gen")
# tab_widget.setCurrentIndex(5)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
# self.scatter_mi = self.scatter.main_curve
# self.scatter.plot("samx", "samy", "bpm4i")
# seventh_tab_layout.addWidget(self.scatter)
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
# tab_widget.setCurrentIndex(6)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# ninth_tab = QWidget()
# ninth_tab_layout = QVBoxLayout(ninth_tab)
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(8)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
#
# self.setWindowTitle("Jupyter Console Window")
# Some buttons for layout testing
self.btn1 = QPushButton("Button 1")
self.btn2 = QPushButton("Button 2")
self.btn3 = QPushButton("Button 3")
self.btn4 = QPushButton("Button 4")
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wf = Waveform()
fifth_tab_layout.addWidget(self.wf)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
tab_widget.setCurrentIndex(4)
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image()
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(5)
seventh_tab = QWidget()
seventh_tab_layout = QVBoxLayout(seventh_tab)
self.scatter = ScatterWaveform()
self.scatter_mi = self.scatter.main_curve
self.scatter.plot("samx", "samy", "bpm4i")
seventh_tab_layout.addWidget(self.scatter)
tab_widget.addTab(seventh_tab, "Scatter Waveform")
tab_widget.setCurrentIndex(6)
eighth_tab = QWidget()
eighth_tab_layout = QVBoxLayout(eighth_tab)
self.mm = MotorMap()
eighth_tab_layout.addWidget(self.mm)
tab_widget.addTab(eighth_tab, "Motor Map")
tab_widget.setCurrentIndex(7)
ninth_tab = QWidget()
ninth_tab_layout = QVBoxLayout(ninth_tab)
self.mwf = MultiWaveform()
ninth_tab_layout.addWidget(self.mwf)
tab_widget.addTab(ninth_tab, "MultiWaveform")
tab_widget.setCurrentIndex(8)
# add stuff to the new Waveform widget
self._init_waveform()
self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
@@ -174,7 +174,7 @@ if __name__ == "__main__": # pragma: no cover
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import os
import time
import traceback
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
@@ -15,7 +14,8 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
@@ -85,21 +85,9 @@ class BECConnector:
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
parent_id: str | None = None,
**kwargs,
):
"""
BECConnector mixin class to handle BEC client and device manager.
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration with specific gui id.
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
**kwargs:
"""
# Extract object_name from kwargs to not pass it to Qt class
object_name = object_name or kwargs.pop("objectName", None)
# Ensure the parent is always the first argument for QObject
@@ -111,9 +99,6 @@ class BECConnector:
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# flag to check if the object was destroyed and its cleanup was called
self._destroyed = False
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
@@ -123,7 +108,7 @@ class BECConnector:
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@SafeSlot()
@pyqtSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
@@ -143,6 +128,7 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
self.parent_id = parent_id
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
@@ -161,8 +147,10 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
if parent_id is None:
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
if connector_parent is not None:
self.parent_id = connector_parent.gui_id
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -171,49 +159,6 @@ class BECConnector:
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
QTimer.singleShot(0, self._update_object_name)
@property
def parent_id(self) -> str | None:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
return connector_parent.gui_id if connector_parent else None
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
@SafeSlot()
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
@@ -283,7 +228,7 @@ class BECConnector:
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
@@ -411,7 +356,7 @@ class BECConnector:
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
# @SafeSlot(str)
# @pyqtSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
@@ -443,7 +388,7 @@ class BECConnector:
self.client = client
self.get_bec_shortcuts()
@SafeSlot(ConnectionConfig) # TODO can be also dict
@pyqtSlot(ConnectionConfig) # TODO can be also dict
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.

View File

@@ -16,9 +16,9 @@ if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
@@ -78,7 +78,7 @@ def list_editable_packages() -> set[str]:
return editable_packages
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
def patch_designer(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
@@ -119,7 +119,7 @@ def patch_designer(cmd_args: list[str] = []): # pragma: no cover
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def find_plugin_paths(base_path: Path):
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
# Patch the designer function
def open_designer(cmd_args: list[str] = []): # pragma: no cover
def main(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
@@ -160,11 +160,7 @@ def open_designer(cmd_args: list[str] = []): # pragma: no cover
set_plugin_environment_variable(plugin_paths)
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
patch_designer()
if __name__ == "__main__": # pragma: no cover

View File

@@ -4,9 +4,8 @@ import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
from typing import TYPE_CHECKING, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
@@ -15,52 +14,30 @@ from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils.serialization import register_serializer_extension
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.rpc_server import RPCServer
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
cb_signal = pyqtSignal(dict, dict)
def __init__(self, cb: Callable, cb_info: dict | None = None):
"""
Initialize the QtThreadSafeCallback.
Args:
cb (Callable): The callback function to be wrapped.
cb_info (dict, optional): Additional information about the callback. Defaults to None.
"""
def __init__(self, cb):
super().__init__()
self.cb_info = cb_info
self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
return id(self.cb)
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -107,7 +84,7 @@ class BECDispatcher:
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str | None = None,
gui_id: str = None,
*args,
**kwargs,
):
@@ -120,9 +97,7 @@ class BECDispatcher:
if self._initialized:
return
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self._slots = collections.defaultdict(set)
self.client = client
if self.client is None:
@@ -145,8 +120,6 @@ class BECDispatcher:
except redis.exceptions.ConnectionError:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
register_serializer_extension()
logger.success("Initialized BECDispatcher")
self.start_cli_server(gui_id=gui_id)
@@ -164,7 +137,6 @@ class BECDispatcher:
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
cb_info: dict | None = None,
**kwargs,
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
@@ -173,15 +145,11 @@ class BECDispatcher:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
self._slots[slot].update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
@@ -194,16 +162,16 @@ class BECDispatcher:
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
for connected_slot in self._registered_slots.values():
for connected_slot in self._slots:
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics:
del self._registered_slots[connected_slot]
self._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
@@ -214,16 +182,11 @@ class BECDispatcher:
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
remove_slots = []
for connected_slot in self._registered_slots.values():
connected_slot.topics.difference_update(set(topics_str))
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
def disconnect_all(self, *args, **kwargs):
"""

View File

@@ -21,7 +21,7 @@ def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
def _loaded_submodules_from_specs(
submodule_specs: tuple[ModuleSpec | None, ...],
submodule_specs: tuple[ModuleSpec | None, ...]
) -> Generator[ModuleType, None, None]:
"""Load all submodules from the given specs."""
for submodule in (

View File

@@ -1,6 +1,6 @@
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
will allow you to decide by yourself when to unblock and execute the callback again."""

View File

@@ -33,6 +33,7 @@ class BECWidget(BECConnector):
gui_id: str | None = None,
theme_update: bool = False,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
parent_id: str | None = None,
**kwargs,
):
"""
@@ -54,7 +55,12 @@ class BECWidget(BECConnector):
"""
super().__init__(
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
client=client,
config=config,
gui_id=gui_id,
parent_dock=parent_dock,
parent_id=parent_id,
**kwargs,
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
@@ -106,8 +112,6 @@ class BECWidget(BECConnector):
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
self.cleanup()
finally:
super().closeEvent(event) # pylint: disable=no-member

View File

@@ -11,7 +11,7 @@ from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_qthemes._main import AccentColors

View File

@@ -266,5 +266,3 @@ class CompactPopupWidget(QWidget):
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from typing import Any, Type
import itertools
from typing import Literal, Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.cli.rpc.rpc_register import RPCRegister
class WidgetContainerUtils:
@@ -72,36 +73,3 @@ class WidgetContainerUtils:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
@staticmethod
def name_is_protected(name: str, container: Any = None) -> bool:
"""
Check if the name is not protected.
Args:
name(str): The name to be checked.
Returns:
bool: True if the name is not protected, False otherwise.
"""
if container is None:
container = BECGuiClient
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
return name in gui_client_methods
@staticmethod
def raise_for_invalid_name(name: str, container: Any = None) -> None:
"""
Check if the name is valid. If not, raise a ValueError.
Args:
name(str): The name to be checked.
Raises:
ValueError: If the name is not valid.
"""
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
if WidgetContainerUtils.name_is_protected(name, container):
raise ValueError(f"Name '{name}' is protected. Please choose another name.")

View File

@@ -96,55 +96,23 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
'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
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
useful to prevent function calls from already deleted objects.
'raise_error' keyword argument can be passed with boolean value if the error should be raised
after the error is displayed. This is useful to propagate the error to the caller but should be used
with great care to avoid segfaults.
The keywords above are stored in a container which can be overridden by passing
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
This is useful to override the default behavior of the decorator for a specific function call.
"""
_slot_params = {
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
}
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):
_override_slot_params = kwargs.pop("_override_slot_params", {})
_slot_params.update(_override_slot_params)
try:
if not _slot_params["verify_sender"] or len(args) == 0:
return method(*args, **kwargs)
_instance = args[0]
if not isinstance(_instance, QObject):
return method(*args, **kwargs)
sender = _instance.sender()
if sender is None:
logger.info(
f"Sender is None for {method.__module__}.{method.__qualname__}, "
"skipping method call."
)
return
return method(*args, **kwargs)
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if _slot_params["popup_error"]:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
if _slot_params["raise_error"]:
raise
return wrapper

View File

@@ -37,7 +37,7 @@ class ExpandableGroupFrame(QFrame):
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._contents = QWidget(self)
self._contents = QWidget()
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)

View File

@@ -1,182 +0,0 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
logger = bec_logger.logger
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
"""
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
super().__init__(parent=parent, client=client, **kwargs)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._form_grid_container = QWidget(parent=self)
self._form_grid = QWidget(parent=self._form_grid_container)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self.populate()
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid: QGridLayout = self._form_grid.layout() # type: ignore
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
"""
A form generated from a pydantic model.
Args:
metadata_model (type[BaseModel]): the model class for which to generate a form.
"""
self._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form)
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
def _form_item_specs(self):
return [
FormItemSpec(name=name, info=info, item_type=info.annotation)
for name, info in self._md_schema.model_fields.items()
]
def update_items_from_schema(self):
self._items = self._form_item_specs()
def populate(self):
self.update_items_from_schema()
super().populate()
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._dict_from_grid()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
return False

View File

@@ -5,8 +5,6 @@ from typing import NamedTuple
from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
@@ -24,7 +22,7 @@ class DesignerPluginInfo:
def __init__(self, plugin_class):
self.plugin_class = plugin_class
self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
@@ -40,6 +38,21 @@ class DesignerPluginInfo:
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
@staticmethod
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()
class DesignerPluginGenerator:
def __init__(self, widget: type):
@@ -109,16 +122,6 @@ class DesignerPluginGenerator:
or bool(init_source.find("super().__init__(parent)") > 0)
)
# for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = bool(
init_source.find("super().__init__(parent=parent") > 0
or init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
if not cls_init_found and not super_init_found:
raise ValueError(
f"Widget class {self.widget.__name__} must call the super constructor with parent."

View File

@@ -1,7 +1,5 @@
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots."""
from __future__ import annotations
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots. """
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot

View File

@@ -1,16 +0,0 @@
import re
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()

View File

@@ -18,9 +18,8 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_lib import messages
from qtpy.QtCore import QObject
else:
@@ -84,7 +83,6 @@ class RPCServer:
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
@@ -191,9 +189,6 @@ class RPCServer:
if not getattr(val, "RPC", True):
continue
data[key] = self._serialize_bec_connector(val)
if self._broadcasted_data == data:
return
self._broadcasted_data = data
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
@@ -217,15 +212,6 @@ class RPCServer:
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
try:
parent = connector.parent()
if isinstance(parent, BECMainWindow):
container_proxy = parent.gui_id
else:
container_proxy = None
except Exception:
container_proxy = None
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
@@ -239,7 +225,6 @@ class RPCServer:
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
}

View File

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

View File

@@ -31,7 +31,6 @@ class SidePanel(QWidget):
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
show_toolbar: bool = True,
):
super().__init__(parent=parent)
@@ -41,7 +40,6 @@ class SidePanel(QWidget):
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._show_toolbar = show_toolbar
self._panel_width = 0
self._panel_height = 0
@@ -61,7 +59,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -73,14 +71,13 @@ class SidePanel(QWidget):
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation in ("left", "right"):
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
if self._orientation == "left":
self.main_layout.addWidget(self.container)
else:
self.main_layout.insertWidget(0, self.container)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
@@ -92,7 +89,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -105,13 +102,11 @@ class SidePanel(QWidget):
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
@@ -238,24 +233,21 @@ class SidePanel(QWidget):
def add_menu(
self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget,
action_id: str | None = None,
icon_name: str | None = None,
tooltip: str | None = None,
title: str | None = None,
) -> int:
):
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
action_id(str | None): The ID of the action. Optional if no toolbar action is needed.
icon_name(str | None): The name of the icon. Optional if no toolbar action is needed.
tooltip(str | None): The tooltip for the action. Optional if no toolbar action is needed.
title(str | None): The title of the panel.
Returns:
int: The index of the added panel, which can be used with show_panel() and switch_to().
title(str): The title of the panel.
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
@@ -286,35 +278,32 @@ class SidePanel(QWidget):
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
if action_id is not None and icon_name is not None and tooltip is not None:
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
if not self.panel_visible:
self.show_panel(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
return index
action.action.toggled.connect(on_action_toggled)
############################################
@@ -343,56 +332,41 @@ class ExampleApp(QMainWindow): # pragma: no cover
self.add_side_menus()
def add_side_menus(self):
# Example 1: With action, icon, and tooltip
widget1 = QWidget()
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
widget=widget1,
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
# Example 2: With action, icon, and tooltip
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
widget=widget2,
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
# Example 3: With action, icon, and tooltip
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
widget=widget3,
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
widget4 = QWidget()
layout4 = QVBoxLayout(widget4)
layout4.addWidget(QLabel("This panel has no toolbar button"))
layout4.addWidget(QLabel("It can only be shown programmatically"))
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
# Example of how to show the hidden panel programmatically after 3 seconds
from qtpy.QtCore import QTimer
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -118,7 +118,7 @@ class IconAction(ToolBarAction):
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
icon = QIcon()
icon.addFile(self.icon_path, size=QSize(20, 20))
self.action = QAction(icon=icon, text=self.tooltip, parent=target)
self.action = QAction(icon, self.tooltip, target)
self.action.setCheckable(self.checkable)
toolbar.addAction(self.action)
@@ -128,7 +128,7 @@ class QtIconAction(ToolBarAction):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action = QAction(self.icon, self.tooltip, parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
@@ -173,7 +173,7 @@ class MaterialIconAction(ToolBarAction):
filled=self.filled,
color=self.color,
)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action = QAction(self.icon, self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
@@ -212,12 +212,12 @@ class DeviceSelectionAction(ToolBarAction):
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget(parent=target)
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(text=f"{self.label}", parent=target)
label = QLabel(f"{self.label}")
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
@@ -280,9 +280,7 @@ class SwitchableToolBarAction(ToolBarAction):
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
)
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
@@ -371,13 +369,13 @@ class WidgetAction(ToolBarAction):
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget(parent=target)
self.container = QWidget()
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(text=f"{self.label}", parent=target)
label_widget = QLabel(f"{self.label}")
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
@@ -439,7 +437,7 @@ class ExpandableMenuAction(ToolBarAction):
)
menu = QMenu(button)
for action_id, action in self.actions.items():
sub_action = QAction(text=action.tooltip, parent=target)
sub_action = QAction(action.tooltip, target)
sub_action.setIconVisibleInMenu(True)
if action.icon_path:
icon = QIcon()
@@ -523,7 +521,7 @@ class ModularToolBar(QToolBar):
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent=parent)
super().__init__(parent)
self.widgets = defaultdict(dict)
self.background_color = background_color

View File

@@ -12,11 +12,10 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
@@ -131,6 +130,7 @@ class BECDock(BECWidget, Dock):
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
parent_id: str | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
@@ -279,7 +279,6 @@ class BECDock(BECWidget, Dock):
widgets.extend(dock.elements.keys())
return widgets
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
@@ -302,9 +301,6 @@ class BECDock(BECWidget, Dock):
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
row = self.layout.rowCount()
@@ -320,7 +316,11 @@ class BECDock(BECWidget, Dock):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget, object_name=name, parent_dock=self, parent=self
widget_type=widget,
object_name=name,
parent_dock=self,
parent_id=self.gui_id,
parent=self,
),
)
else:
@@ -416,7 +416,6 @@ class BECDock(BECWidget, Dock):
self.delete_all()
self.widgets.clear()
super().cleanup()
self.deleteLater()
def close(self):
"""

View File

@@ -14,7 +14,6 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
@@ -102,8 +101,8 @@ class BECDockArea(BECWidget, QWidget):
self._instructions_visible = True
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.dark_mode_button = DarkModeButton(parent=self, parent_id=self.gui_id, toolbar=True)
self.dock_area = DockArea()
self.toolbar = ModularToolBar(
parent=self,
actions={
@@ -182,7 +181,7 @@ class BECDockArea(BECWidget, QWidget):
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget(parent=self)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
@@ -243,8 +242,7 @@ class BECDockArea(BECWidget, QWidget):
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
name = pascal_to_snake(widget_name)
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
@@ -308,8 +306,6 @@ class BECDockArea(BECWidget, QWidget):
"""
if state is None:
state = self.config.docks_state
if state is None:
return
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
@@ -366,8 +362,6 @@ class BECDockArea(BECWidget, QWidget):
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self.object_name} and id {self.gui_id}."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
@@ -376,6 +370,7 @@ class BECDockArea(BECWidget, QWidget):
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
parent_id=self.gui_id,
closable=closable,
)
dock.config.position = position
@@ -445,8 +440,12 @@ class BECDockArea(BECWidget, QWidget):
Cleanup the dock area.
"""
self.delete_all()
self.toolbar.close()
self.toolbar.deleteLater()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
super().cleanup()
def show(self):

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import math
import sys
from typing import Dict, Literal, Optional, Set, Tuple, Union
@@ -53,7 +51,7 @@ class LayoutManagerWidget(QWidget):
self,
widget: QWidget | str,
row: int | None = None,
col: int | None = None,
col: Optional[int] = None,
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
@@ -138,39 +136,6 @@ class LayoutManagerWidget(QWidget):
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
# Determine new widget position based on the specified relative position
# If adding to the left or right with shifting, shift the entire column
if (
position in ("left", "right")
and shift_existing
and shift_direction in ("left", "right")
):
column = ref_col
# Collect all rows in this column and sort for safe shifting
rows = sorted(
{row for (row, col) in self.position_widgets.keys() if col == column},
reverse=(shift_direction == "right"),
)
# Shift each widget in the column
for r in rows:
self.shift_widgets(direction=shift_direction, start_row=r, start_col=column)
# Update reference widget's position after the column shift
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
new_row = ref_row
# Compute insertion column based on relative position
if position == "left":
new_col = ref_col - ref_colspan
else:
new_col = ref_col + ref_colspan
# Add the new widget without triggering another shift
return self.add_widget(
widget=widget,
row=new_row,
col=new_col,
rowspan=rowspan,
colspan=colspan,
shift_existing=False,
)
if position == "left":
new_row = ref_row
new_col = ref_col - 1

View File

@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = True
RPC = False
def __init__(
self,

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
RPC = True
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -54,20 +54,9 @@ class StopButton(BECWidget, QWidget):
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
class MyGui(QWidget):
def __init__(self):
super().__init__()
self.setLayout(QVBoxLayout())
# Create and add the StopButton to the layout
self.stop_button = StopButton()
self.layout().addWidget(self.stop_button)
# Example of how this custom GUI might be used:
app = QApplication([])
my_gui = MyGui()
my_gui.show()
app = QApplication(sys.argv)
w = StopButton()
w.show()
sys.exit(app.exec_())

View File

@@ -12,16 +12,7 @@ class PositionIndicator(BECWidget, QWidget):
Display a position within a defined range, e.g. motor limits.
"""
USER_ACCESS = [
"set_value",
"set_range",
"vertical",
"vertical.setter",
"indicator_width",
"indicator_width.setter",
"rounded_corners",
"rounded_corners.setter",
]
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
PLUGIN = True
ICON_NAME = "horizontal_distribute"
@@ -218,12 +209,6 @@ class PositionIndicator(BECWidget, QWidget):
@Slot(int)
@Slot(float)
def set_value(self, position: float):
"""
Set the position of the indicator
Args:
position: The new position of the indicator
"""
self.position = position
self.update()

View File

@@ -31,7 +31,6 @@ class PositionerBox(PositionerBoxBase):
dimensions = (234, 224)
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)

View File

@@ -33,7 +33,6 @@ class PositionerBox2D(PositionerBoxBase):
ui_file = "positioner_box_2d.ui"
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
device_changed_hor = Signal(str, str)

View File

@@ -1,4 +1,4 @@
"""Module for a PositionerGroup widget to control a positioner device."""
""" Module for a PositionerGroup widget to control a positioner device."""
from __future__ import annotations

View File

@@ -82,7 +82,7 @@ class DeviceInputBase(BECWidget):
ReadoutPriority.ON_REQUEST: "readout_on_request",
}
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
def __init__(self, client=None, config=None, gui_id: str | None = None, **kwargs):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
@@ -90,9 +90,7 @@ class DeviceInputBase(BECWidget):
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
super().__init__(client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs)
self.get_bec_shortcuts()
self._device_filter = []
self._readout_filter = []
@@ -397,7 +395,7 @@ class DeviceInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device, None)
dev = getattr(self.dev, device.lower(), None)
if dev is None:
raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device."

View File

@@ -250,7 +250,7 @@ class DeviceSignalInputBase(BECWidget):
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
"""
self.validate_device(device)
dev = getattr(self.dev, device, None)
dev = getattr(self.dev, device.lower(), None)
if dev is None:
logger.warning(f"Device {device} not found in devicemanager.")
return None

View File

@@ -140,7 +140,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text)
self.device_selected.emit(input_text.lower())
else:
self._is_valid_input = False
self.update()

View File

@@ -147,7 +147,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
"""
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text)
self.device_selected.emit(input_text.lower())
else:
self._is_valid_input = False
self.update()

View File

@@ -64,6 +64,7 @@ class ScanControl(BECWidget, QWidget):
default_scan: str | None = None,
**kwargs,
):
if config is None:
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
@@ -165,7 +166,7 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self._metadata_form = ScanMetadata()
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)

View File

@@ -234,7 +234,7 @@ class ScanGroupBox(QGroupBox):
continue
if default == "_empty":
default = None
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
widget = widget_class(arg_name=arg_name, default=default)
if isinstance(widget, DeviceLineEdit):
widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = ""
@@ -274,24 +274,12 @@ class ScanGroupBox(QGroupBox):
for widget in self.widgets[-len(self.inputs) :]:
if isinstance(widget, DeviceLineEdit):
self.selected_devices[widget] = ""
widget.close()
widget.deleteLater()
self.widgets = self.widgets[: -len(self.inputs)]
selected_devices_str = " ".join(self.selected_devices.values())
self.device_selected.emit(selected_devices_str.strip())
def remove_all_widget_bundles(self):
"""Remove every widget bundle from the scan control layout."""
for widget in list(self.widgets):
if isinstance(widget, DeviceLineEdit):
self.selected_devices.pop(widget, None)
widget.close()
widget.deleteLater()
self.layout.removeWidget(widget)
self.widgets.clear()
self.device_selected.emit("")
@Property(bool)
def hide_add_remove_buttons(self):
return self._hide_add_remove_buttons
@@ -360,21 +348,10 @@ class ScanGroupBox(QGroupBox):
self._set_kwarg_parameters(parameters)
def _set_arg_parameters(self, parameters: list):
self.remove_all_widget_bundles()
if not parameters:
return
inputs_per_bundle = len(self.inputs)
if inputs_per_bundle == 0:
return
bundles_needed = -(-len(parameters) // inputs_per_bundle)
for row in range(1, bundles_needed + 1):
self.add_input_widgets(self.inputs, row)
for i, value in enumerate(parameters):
WidgetIO.set_value(self.widgets[i], value)
while len(parameters) != len(self.widgets):
self.add_widget_bundle()
for i, parameter in enumerate(parameters):
WidgetIO.set_value(self.widgets[i], parameter)
def _set_kwarg_parameters(self, parameters: dict):
for widget in self.widgets:

View File

@@ -16,7 +16,7 @@ class DapComboBox(BECWidget, QWidget):
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
gui_id: GUI ID.--
default: Default device name.
"""
@@ -154,7 +154,9 @@ class DapComboBox(BECWidget, QWidget):
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.available_models = [
model for model in self.client.dap._available_dap_plugins.keys()
]
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)

View File

@@ -0,0 +1,193 @@
import inspect
from bec_lib.signature_serializer import deserialize_dtype
from bec_server.data_processing.dap_framework_refactoring.dap_blocks import (
BlockWithLotsOfArgs,
DAPBlock,
GradientBlock,
SmoothBlock,
)
from pydantic.fields import FieldInfo
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QLabel,
QLayout,
QRadioButton,
QScrollArea,
)
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag, QPixmap
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QMainWindow,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
from tests.unit_tests.test_scan_metadata import metadata_widget
class DragItem(ExpandableGroupFrame):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setContentsMargins(25, 5, 25, 5)
self._layout = QVBoxLayout()
self.set_layout(self._layout)
def mouseMoveEvent(self, e):
if e.buttons() == Qt.MouseButton.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
pixmap = QPixmap(self.size())
self.render(pixmap)
drag.setPixmap(pixmap)
drag.exec(Qt.DropAction.MoveAction)
class DragWidget(QWidget):
"""
Generic list sorting handler.
"""
orderChanged = Signal(list)
def __init__(self, *args, orientation=Qt.Orientation.Vertical, **kwargs):
super().__init__(*args, **kwargs)
self.setAcceptDrops(True)
# Store the orientation for drag checks later.
self.orientation = orientation
if self.orientation == Qt.Orientation.Vertical:
self.blayout = QVBoxLayout()
else:
self.blayout = QHBoxLayout()
self.setLayout(self.blayout)
def dragEnterEvent(self, e):
e.accept()
def dropEvent(self, e):
pos = e.position()
widget = e.source()
self.blayout.removeWidget(widget)
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
if self.orientation == Qt.Orientation.Vertical:
# Drag drop vertically.
drop_here = pos.y() < w.y() + w.size().height() // 2
else:
# Drag drop horizontally.
drop_here = pos.x() < w.x() + w.size().width() // 2
if drop_here:
break
else:
# We aren't on the left hand/upper side of any widget,
# so we're at the end. Increment 1 to insert after.
n += 1
self.blayout.insertWidget(n, widget)
self.orderChanged.emit(self.get_item_data())
e.accept()
def add_item(self, item):
self.blayout.addWidget(item)
def get_item_data(self):
data = []
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w: "DAPBlockWidget" = self.blayout.itemAt(n).widget()
data.append(w._title.text())
return data
class DAPBlockWidget(BECWidget, DragItem):
def __init__(
self,
parent=None,
content: type[DAPBlock] = None,
client=None,
gui_id: str | None = None,
**kwargs,
):
super().__init__(
parent=parent,
client=client,
gui_id=gui_id,
title=content.__name__,
**kwargs,
)
self._content = content
self.add_form(self._content)
def add_form(self, block_type: type[DAPBlock]):
run_signature = inspect.signature(block_type.run)
self._title.setText(block_type.__name__)
layout = self._contents.layout()
if layout is None:
return
self._add_widgets_for_signature(layout, run_signature)
def _add_widgets_for_signature(self, layout: QLayout, signature: inspect.Signature):
for arg_name, arg_spec in signature.parameters.items():
annotation: str | type = arg_spec.annotation
if isinstance(annotation, str):
annotation = deserialize_dtype(annotation) or annotation
w = QWidget()
w.setLayout(QHBoxLayout())
w.layout().addWidget(QLabel(arg_name))
w.layout().addWidget(
widget_from_type(annotation)(
FieldInfo(
annotation=annotation
) # FIXME this class should not be initialised directly...
)
)
w.layout().addWidget(QLabel(str(annotation)))
layout.addWidget(w)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.drag = DragWidget(orientation=Qt.Orientation.Vertical)
for block_type in [SmoothBlock, GradientBlock, BlockWithLotsOfArgs] * 2:
item = DAPBlockWidget(content=block_type)
self.drag.add_item(item)
# Print out the changed order.
self.drag.orderChanged.connect(print)
container = QWidget()
layout = QVBoxLayout()
layout.addStretch(1)
layout.addWidget(self.drag)
layout.addStretch(1)
container.setLayout(layout)
self.setCentralWidget(container)
if __name__ == "__main__":
app = QApplication([])
w = MainWindow()
w.show()
app.exec()

View File

@@ -0,0 +1,6 @@
"""Panel to compose DAP task runs
- new ones are added as tabs
- can be enabled and disabled, continuous and oneoff
- fill in extra kwargs using thing from MD widget
- output to topic for the name, which looks like a data topic
"""

View File

@@ -1,5 +1,5 @@
"""
BECConsole is a Qt widget that runs a Bash shell.
BECConsole is a Qt widget that runs a Bash shell.
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
@@ -56,12 +56,12 @@ control_keys_mapping = {
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
@@ -72,10 +72,10 @@ control_keys_mapping = {
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
}
normal_keys_mapping = {
@@ -89,7 +89,7 @@ normal_keys_mapping = {
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0e",
QtCore.Qt.Key_Down: b"\x0E",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",

View File

@@ -0,0 +1,7 @@
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
AdditionalMetadataTableModel,
)
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
__all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]

View File

@@ -2,13 +2,11 @@ from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from types import UnionType
from typing import Callable, Protocol
from typing import TYPE_CHECKING, Callable, get_args
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import FieldInfo
from pydantic import BaseModel, Field
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
@@ -35,22 +33,12 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_precision,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
name: str
info: FieldInfo = FieldInfo()
class ClearableBoolEntry(QWidget):
stateChanged = Signal()
@@ -94,20 +82,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
class DynamicFormItem(QWidget):
class MetadataWidget(QWidget):
valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._spec = spec
self._info = info
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
self._default = field_default(self._spec.info)
self._desc = self._spec.info.description
self._default = field_default(self._info)
self._desc = self._info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(spec.info):
if clearable_required(info):
self._add_clear_button()
@abstractmethod
@@ -138,15 +126,15 @@ class DynamicFormItem(QWidget):
self.valueChanged.emit()
class StrMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
class StrMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QLineEdit()
self._layout.addWidget(self._main_widget)
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
min_length, max_length = field_minlen(self._info), field_maxlen(self._info)
if max_length:
self._main_widget.setMaxLength(max_length)
self._main_widget.setToolTip(
@@ -167,15 +155,15 @@ class StrMetadataField(DynamicFormItem):
self._main_widget.setText(value)
class IntMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
class IntMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
min_, max_ = field_limits(self._info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
@@ -196,18 +184,18 @@ class IntMetadataField(DynamicFormItem):
self._main_widget.setValue(value)
class FloatDecimalMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
class FloatDecimalMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
min_, max_ = field_limits(self._info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
precision = field_precision(self._info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -230,13 +218,13 @@ class FloatDecimalMetadataField(DynamicFormItem):
self._main_widget.setValue(value)
class BoolMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
class BoolMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
self._main_widget.stateChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
if clearable_required(self._spec.info):
if clearable_required(self._info):
self._main_widget = ClearableBoolEntry()
else:
self._main_widget = QCheckBox()
@@ -251,7 +239,7 @@ class BoolMetadataField(DynamicFormItem):
self._main_widget.setChecked(value)
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:
@@ -261,7 +249,9 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
logger.warning(
f"Type {annotation} is not (yet) supported in metadata form creation."
)
return StrMetadataField

View File

@@ -9,7 +9,7 @@ from annotated_types import Ge, Gt, Le, Lt
from bec_lib.logger import bec_logger
from pydantic_core import PydanticUndefined
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger

View File

@@ -16,20 +16,12 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeSlot
class DictBackedTableModel(QAbstractTableModel):
class AdditionalMetadataTableModel(QAbstractTableModel):
def __init__(self, data):
"""A model to go with DictBackedTable, which represents key-value pairs
to be displayed in a TreeWidget.
Args:
data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__()
self._data: list[list[str]] = data
self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring
# see QAbstractTableModel documentation for these methods
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
) -> Any:
@@ -57,10 +49,6 @@ class DictBackedTableModel(QAbstractTableModel):
return False
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
Args:
keys (list[str]): list of keys which are forbidden."""
self._disallowed_keys = keys
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
@@ -107,21 +95,16 @@ class DictBackedTableModel(QAbstractTableModel):
return dict(self._data)
class DictBackedTable(QWidget):
class AdditionalMetadataTable(QWidget):
delete_rows = Signal(list)
def __init__(self, initial_data: list[list[str]]):
"""Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__()
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._table_model = DictBackedTableModel(initial_data)
self._table_model = AdditionalMetadataTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
@@ -143,21 +126,15 @@ class DictBackedTable(QWidget):
self.delete_rows.connect(self._table_model.delete_rows)
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
row_indices = list({r.row() for r in cells})
if row_indices:
self.delete_rows.emit(row_indices)
def dump_dict(self):
"""Get the current content of the table as a dict"""
return self._table_model.dump_dict()
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
Args:
keys (list[str]): list of keys which are forbidden."""
self._table_model.update_disallowed_keys(keys)
@@ -167,6 +144,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -1,21 +1,52 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_lib.metadata_schema import get_metadata_schema_for_scan
from pydantic import Field
from qtpy.QtWidgets import QApplication, QComboBox, QHBoxLayout, QVBoxLayout, QWidget
from bec_qthemes import material_icon
from pydantic import Field, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
)
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class ScanMetadata(PydanticModelForm):
class PydanticModelForm(BECWidget, QWidget):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified."""
PLUGIN = True
ICON_NAME = "list_alt"
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
RPC = False
def __init__(
self,
parent=None,
@@ -24,36 +55,126 @@ class ScanMetadata(PydanticModelForm):
initial_extras: list[list[str]] | None = None,
**kwargs,
):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified.
super().__init__(parent=parent, client=client, **kwargs)
Args:
scan_name (str): The scan for which to generate a metadata form
Initial_extras (list[list[str]]): Initial data with which to populate the additional
metadata table - inner lists should be key-value pairs
"""
self.set_schema(scan_name)
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
self._layout.addWidget(self._required_md_box)
self._required_md_box_layout = QHBoxLayout()
self._required_md_box.set_layout(self._required_md_box_layout)
self._md_grid = QWidget()
self._required_md_box_layout.addWidget(self._md_grid)
self._grid_container = QVBoxLayout()
self._md_grid.setLayout(self._grid_container)
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._additional_md_box = ExpandableGroupFrame(
"Additional metadata", expanded=False
)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
self._layout.addWidget(self._additional_md_box)
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
self._additional_md_box_layout.addWidget(self._additional_metadata)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.populate()
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
self.set_schema_from_scan(scan_name)
self.set_schema(scan_name)
self.populate()
self.validate_form()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_full_model_dict()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
def get_full_model_dict(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def set_schema(self, scan_name: str | None = None):
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
def populate(self):
self._clear_grid()
self._populate()
def _populate(self):
self._additional_metadata.update_disallowed_keys(
list(self._md_schema.model_fields.keys())
)
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
self._add_griditem(field_name, info, i)
def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
grid = self._md_grid_layout
label = QLabel(info.title or field_name)
label.setProperty("_model_field_name", field_name)
label.setToolTip(info.description or field_name)
grid.addWidget(label, row, 0)
widget = widget_from_type(info.annotation)(info)
widget.valueChanged.connect(self.validate_form)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid = self._md_grid_layout
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'MetadataWidget's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
while self._md_grid_layout.count():
item = self._md_grid_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self._md_grid_layout.deleteLater()
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._md_grid.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
self._md_grid_layout = QGridLayout()
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
@SafeProperty(bool)
def hide_optional_metadata(self): # type: ignore
"""Property to hide the optional metadata table."""
@@ -68,25 +189,11 @@ class ScanMetadata(PydanticModelForm):
"""
self._additional_md_box.setVisible(not hide)
def get_form_data(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
super().populate()
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
class ScanMetadata(PydanticModelForm): ...
if __name__ == "__main__": # pragma: no cover
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
# pylint: disable=disallowed-name
from unittest.mock import patch
from bec_lib.metadata_schema import BasicScanMetadata
@@ -94,15 +201,21 @@ if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
class ExampleSchema1(BasicScanMetadata):
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
abc: int = Field(
gt=0, lt=2000, description="Heating temperature abc", title="A B C"
)
foo: str = Field(
max_length=12, description="Sample database code", default="DEF123"
)
xyz: Decimal = Field(decimal_places=4)
baz: bool
class ExampleSchema2(BasicScanMetadata):
checkbox_up_top: bool
checkbox_again: bool = Field(
title="Checkbox Again", description="this one defaults to True", default=True
title="Checkbox Again",
description="this one defaults to True",
default=True,
)
different_items: int | None = Field(
None, description="This is just one different item...", gt=-100, lt=0
@@ -115,7 +228,11 @@ if __name__ == "__main__": # pragma: no cover
with patch(
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
lambda: {
"scan1": ExampleSchema1,
"scan2": ExampleSchema2,
"scan3": ExampleSchema3,
},
):
app = QApplication([])
w = QWidget()

View File

@@ -117,7 +117,6 @@ class WebsiteWidget(BECWidget, QWidget):
Cleanup the widget
"""
self.website.page().deleteLater()
super().cleanup()
if __name__ == "__main__":

View File

@@ -144,7 +144,7 @@ class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
RPC = True
RPC = False
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)

View File

@@ -27,7 +27,7 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ImageConfig(ConnectionConfig):
color_map: str = Field(
"plasma", description="The colormap of the figure widget.", validate_default=True
"magma", description="The colormap of the figure widget.", validate_default=True
)
color_bar: Literal["full", "simple"] | None = Field(
None, description="The type of the color bar."
@@ -79,6 +79,12 @@ class Image(PlotBase):
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
# ImageView Specific Settings
"color_map",
"color_map.setter",
@@ -105,8 +111,8 @@ class Image(PlotBase):
"fft.setter",
"log",
"log.setter",
"num_rotation_90",
"num_rotation_90.setter",
"rotation",
"rotation.setter",
"transpose",
"transpose.setter",
"image",
@@ -131,13 +137,13 @@ class Image(PlotBase):
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
self._main_image = ImageItem(parent_image=self)
self._main_image = ImageItem(parent_image=self, parent_id=self.gui_id)
self.plot_item.addItem(self._main_image)
self.scan_id = None
# Default Color map to plasma
self.color_map = "plasma"
# Default Color map to magma
self.color_map = "magma"
################################################################################
# Widget Specific GUI interactions
@@ -650,21 +656,21 @@ class Image(PlotBase):
self._main_image.log = enable
@SafeProperty(int)
def num_rotation_90(self) -> int:
def rotation(self) -> int:
"""
The number of 90° rotations to apply counterclockwise.
The number of 90° rotations to apply.
"""
return self._main_image.num_rotation_90
return self._main_image.rotation
@num_rotation_90.setter
def num_rotation_90(self, value: int):
@rotation.setter
def rotation(self, value: int):
"""
Set the number of 90° rotations to apply counterclockwise.
Set the number of 90° rotations to apply.
Args:
value(int): The number of 90° rotations to apply.
"""
self._main_image.num_rotation_90 = value
self._main_image.rotation = value
@SafeProperty(bool)
def transpose(self) -> bool:
@@ -756,19 +762,6 @@ class Image(PlotBase):
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(False)
else:
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(True)
self.selection_bundle.device_combo_box.setCurrentText("")
self.selection_bundle.dim_combo_box.setCurrentText("auto")
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(False)
################################################################################
# Image Update Methods
@@ -819,7 +812,6 @@ class Image(PlotBase):
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
self._main_image.config.monitor = None
self._sync_device_selection()
########################################
# 1D updates

View File

@@ -24,7 +24,7 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("plasma", description="The color map of the image.")
color_map: str | None = Field("magma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.")
v_range: tuple[float | int, float | int] | None = Field(
@@ -61,8 +61,8 @@ class ImageItem(BECConnector, pg.ImageItem):
"fft.setter",
"log",
"log.setter",
"num_rotation_90",
"num_rotation_90.setter",
"rotation",
"rotation.setter",
"transpose",
"transpose.setter",
"get_data",
@@ -86,6 +86,7 @@ class ImageItem(BECConnector, pg.ImageItem):
self.set_parent(parent_image)
else:
self.parent_image = None
self.parent_id = None
super().__init__(config=config, gui_id=gui_id, **kwargs)
self.raw_data = None
@@ -97,6 +98,7 @@ class ImageItem(BECConnector, pg.ImageItem):
def set_parent(self, parent: BECConnector):
self.parent_image = parent
self.parent_id = parent.gui_id
def parent(self):
return self.parent_image
@@ -239,13 +241,13 @@ class ImageItem(BECConnector, pg.ImageItem):
self._process_image()
@property
def num_rotation_90(self) -> Optional[int]:
def rotation(self) -> Optional[int]:
"""Get or set the number of 90° rotations to apply."""
return self.config.processing.num_rotation_90
return self.config.processing.rotation
@num_rotation_90.setter
def num_rotation_90(self, value: Optional[int]):
self.config.processing.num_rotation_90 = value
@rotation.setter
def rotation(self, value: Optional[int]):
self.config.processing.rotation = value
self._process_image()
@property
@@ -273,7 +275,3 @@ class ImageItem(BECConnector, pg.ImageItem):
self.raw_data = None
self.buffer = []
self.max_len = 0
def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
self.clear()

View File

@@ -37,7 +37,7 @@ class ProcessingConfig(BaseModel):
transpose: bool = Field(
False, description="Whether to transpose the monitor data before displaying."
)
num_rotation_90: int = Field(
rotation: int = Field(
0, description="The rotation angle of the monitor data before displaying."
)
stats: ImageStats = Field(
@@ -140,8 +140,8 @@ class ImageProcessor(QObject):
"""Core processing logic without threading overhead."""
if self.config.fft:
data = self.FFT(data)
if self.config.num_rotation_90 is not None:
data = self.rotation(data, self.config.num_rotation_90)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:

View File

@@ -40,7 +40,7 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
# 2) Dimension combo box
self.dim_combo_box = QComboBox(parent=self.target_widget)
self.dim_combo_box = QComboBox()
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")

View File

@@ -55,24 +55,24 @@ class ImageProcessingToolbarBundle(ToolbarBundle):
@SafeSlot()
def rotate_right(self):
if self.target_widget.num_rotation_90 is None:
if self.target_widget.rotation is None:
return
rotation = (self.target_widget.num_rotation_90 - 1) % 4
self.target_widget.num_rotation_90 = rotation
rotation = (self.target_widget.rotation - 1) % 4
self.target_widget.rotation = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.num_rotation_90 is None:
if self.target_widget.rotation is None:
return
rotation = (self.target_widget.num_rotation_90 + 1) % 4
self.target_widget.num_rotation_90 = rotation
rotation = (self.target_widget.rotation + 1) % 4
self.target_widget.rotation = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.num_rotation_90 = 0
self.target_widget.rotation = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)

View File

@@ -25,7 +25,7 @@ logger = bec_logger.logger
class MultiWaveformConfig(ConnectionConfig):
color_palette: str | None = Field(
"plasma", description="The color palette of the figure widget.", validate_default=True
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: int | None = Field(
200, description="The maximum number of curves to display on the plot."
@@ -308,7 +308,7 @@ class MultiWaveform(PlotBase):
################################################################################
@SafeSlot(popup_error=True)
def plot(self, monitor: str, color_palette: str | None = "plasma"):
def plot(self, monitor: str, color_palette: str | None = "magma"):
"""
Create a plot for the given monitor.
Args:

View File

@@ -29,7 +29,9 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
# Monitor Selection
self.monitor = DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=ReadoutPriority.ASYNC
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=ReadoutPriority.ASYNC,
parent_id=self.target_widget.gui_id,
)
self.monitor.addItem("", None)
self.monitor.setCurrentText("")
@@ -38,7 +40,7 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False))
# Colormap Selection
self.colormap_widget = BECColorMapWidget(cmap="plasma")
self.colormap_widget = BECColorMapWidget(cmap="magma", parent_id=self.target_widget.gui_id)
self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox

View File

@@ -98,11 +98,10 @@ class PlotBase(BECWidget, QWidget):
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self._init_toolbar()
# PlotItem Addons
@@ -796,7 +795,6 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.showAxis("top", value)
self.plot_item.showAxis("right", value)
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
@@ -816,7 +814,6 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.showAxis("bottom", value)
self.plot_item.showAxis("left", value)
self._apply_x_label()
self._apply_y_label()
self.property_changed.emit("inner_axes", value)

View File

@@ -38,7 +38,7 @@ class ScatterCurveConfig(ConnectionConfig):
"solid", description="The style of the pen of the curve."
)
color_map: str | None = Field(
"plasma", description="The color palette of the figure widget.", validate_default=True
"magma", description="The color palette of the figure widget.", validate_default=True
)
x_device: ScatterDeviceSignal | None = Field(
None, description="The x device signal of the scatter waveform."
@@ -78,8 +78,8 @@ class ScatterCurve(BECConnector, pg.PlotDataItem):
self.config = config
name = config.label
self.parent_item = parent_item
object_name = name.replace("-", "_").replace(" ", "_") if name else None
super().__init__(name=name, object_name=object_name, config=config, gui_id=gui_id, **kwargs)
self.parent_id = self.parent_item.gui_id
super().__init__(name=name, config=config, gui_id=gui_id, **kwargs)
self.data_z = None # color scaling needs to be cashed for changing colormap
self.apply_config()

View File

@@ -30,7 +30,7 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ScatterWaveformConfig(ConnectionConfig):
color_map: str | None = Field(
"plasma",
"magma",
description="The color map of the z scaling of scatter waveform.",
validate_default=True,
)
@@ -266,7 +266,7 @@ class ScatterWaveform(PlotBase):
x_entry: None | str = None,
y_entry: None | str = None,
z_entry: None | str = None,
color_map: str | None = "plasma",
color_map: str | None = "magma",
label: str | None = None,
validate_bec: bool = True,
) -> ScatterCurve:
@@ -333,13 +333,12 @@ class ScatterWaveform(PlotBase):
# To have only one main curve
if self._main_curve is not None:
self.rpc_register.remove_rpc(self._main_curve)
self.rpc_register.broadcast()
self.plot_item.removeItem(self._main_curve)
self._main_curve.deleteLater()
self._main_curve = None
self._main_curve = ScatterCurve(parent_item=self, config=config, name=config.label)
self._main_curve = ScatterCurve(
parent_item=self, config=config, gui_id=self.gui_id, name=config.label
)
self.plot_item.addItem(self._main_curve)
self.sync_signal_update.emit()

View File

@@ -92,7 +92,8 @@ class Curve(BECConnector, pg.PlotDataItem):
else:
self.config = config
self.parent_item = parent_item
object_name = name.replace("-", "_").replace(" ", "_") if name else None
self.parent_id = self.parent_item.gui_id
object_name = name.replace("-", "_") if name else None
super().__init__(name=name, object_name=object_name, config=config, gui_id=gui_id, **kwargs)
self.apply_config()

View File

@@ -31,9 +31,6 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING: # pragma: no cover
@@ -43,6 +40,49 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
class ColorButton(QPushButton):
"""A QPushButton subclass that displays a color.
The background is set to the given color and the button text is the hex code.
The text color is chosen automatically (black if the background is light, white if dark)
to guarantee good readability.
"""
def __init__(self, color="#000000", parent=None):
"""Initialize the color button.
Args:
color (str): The initial color in hex format (e.g., '#000000').
parent: Optional QWidget parent.
"""
super().__init__(parent)
self.set_color(color)
def set_color(self, color):
"""Set the button's color and update its appearance.
Args:
color (str or QColor): The new color to assign.
"""
if isinstance(color, QColor):
self._color = color.name()
else:
self._color = color
self._update_appearance()
def color(self):
"""Return the current color in hex."""
return self._color
def _update_appearance(self):
"""Update the button style based on the background color's brightness."""
c = QColor(self._color)
brightness = c.lightnessF()
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)
class CurveRow(QTreeWidgetItem):
DELETE_BUTTON_COLOR = "#CC181E"
"""A unified row that can represent either a device or a DAP curve.
@@ -153,7 +193,7 @@ class CurveRow(QTreeWidgetItem):
def _init_style_controls(self):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
self.color_button = ColorButtonNative(color=self.config.color)
self.color_button = ColorButton(self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.tree.setItemWidget(self, 3, self.color_button)
@@ -244,11 +284,6 @@ class CurveRow(QTreeWidgetItem):
self.dap_combo.deleteLater()
self.dap_combo = None
if getattr(self, "color_button", None) is not None:
self.color_button.close()
self.color_button.deleteLater()
self.color_button = None
# Remove the item from the tree widget
index = self.tree.indexOfTopLevelItem(self)
if index != -1:
@@ -302,8 +337,8 @@ class CurveRow(QTreeWidgetItem):
self.config.label = f"{parent_conf.label}-{new_dap}"
# Common style fields
self.config.color = self.color_button.color
self.config.symbol_color = self.color_button.color
self.config.color = self.color_button.color()
self.config.symbol_color = self.color_button.color()
self.config.pen_style = self.style_combo.currentText()
self.config.pen_width = self.width_spin.value()
self.config.symbol_size = self.symbol_spin.value()
@@ -338,7 +373,7 @@ class CurveTree(BECWidget, QWidget):
if self.waveform and hasattr(self.waveform, "color_palette"):
self.color_palette = self.waveform.color_palette
else:
self.color_palette = "plasma"
self.color_palette = "magma"
self.get_bec_shortcuts()
@@ -351,7 +386,7 @@ class CurveTree(BECWidget, QWidget):
def _init_toolbar(self):
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
add = MaterialIconAction(
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
)
@@ -378,7 +413,7 @@ class CurveTree(BECWidget, QWidget):
self.toolbar.add_action("renormalize_colors", renorm_action, self)
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "plasma")
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "magma")
self.toolbar.addWidget(self.colormap_widget)
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)

View File

@@ -15,7 +15,6 @@ from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBo
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
from bec_widgets.utils.colors import Colors, set_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbar import MaterialIconAction
@@ -31,7 +30,7 @@ logger = bec_logger.logger
# noinspection PyDataclass
class WaveformConfig(ConnectionConfig):
color_palette: str | None = Field(
"plasma", description="The color palette of the figure widget.", validate_default=True
"magma", description="The color palette of the figure widget.", validate_default=True
)
model_config: dict = {"validate_assignment": True}
@@ -90,8 +89,6 @@ class Waveform(PlotBase):
"curves",
"x_mode",
"x_mode.setter",
"x_entry",
"x_entry.setter",
"color_palette",
"color_palette.setter",
"plot",
@@ -647,9 +644,7 @@ class Waveform(PlotBase):
# Decide label if not provided
if label is None:
if source == "custom":
label = WidgetContainerUtils.generate_unique_name(
"Curve", [c.object_name for c in self.curves]
)
label = f"Curve {len(self.curves) + 1}"
else:
label = f"{y_name}-{y_entry}"
@@ -774,9 +769,7 @@ class Waveform(PlotBase):
label = config.label
if not label:
# Fallback label
label = WidgetContainerUtils.generate_unique_name(
"Curve", [c.object_name for c in self.curves]
)
label = f"Curve {len(self.curves) + 1}"
config.label = label
# Check for duplicates
@@ -1182,11 +1175,10 @@ class Waveform(PlotBase):
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, name),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
logger.info(f"Setup async curve {name}")
@SafeSlot(dict, dict, verify_sender=True)
@SafeSlot(dict, dict)
def on_async_readback(self, msg, metadata):
"""
Get async data readback. This code needs to be fast, therefor we try
@@ -1205,14 +1197,6 @@ class Waveform(PlotBase):
msg(dict): Message with the async data.
metadata(dict): Metadata of the message.
"""
sender = self.sender()
if not hasattr(sender, "cb_info"):
logger.info(f"Sender {sender} has no cb_info.")
return
scan_id = sender.cb_info.get("scan_id", None)
if scan_id != self.scan_id:
logger.info("Scan ID mismatch, ignoring async readback.")
instruction = metadata.get("async_update", {}).get("type")
if instruction not in ["add", "add_slice", "replace"]:
logger.warning(f"Invalid async update instruction: {instruction}")
@@ -1221,7 +1205,6 @@ class Waveform(PlotBase):
plot_mode = self.x_axis_mode["name"]
for curve in self._async_curves:
x_data = None # Reset x_data
y_data = None # Reset y_data
# Get the curve data
async_data = msg["signals"].get(curve.config.signal.entry, None)
if async_data is None:
@@ -1765,10 +1748,12 @@ class Waveform(PlotBase):
self.proxy_dap_request.cleanup()
self.clear_all()
if self.curve_settings_dialog is not None:
self.curve_settings_dialog.reject()
self.curve_settings_dialog.close()
self.curve_settings_dialog.deleteLater()
self.curve_settings_dialog = None
if self.dap_summary_dialog is not None:
self.dap_summary_dialog.reject()
self.dap_summary_dialog.close()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
super().cleanup()

View File

@@ -21,7 +21,6 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum",
"label_template",
"label_template.setter",
"_get_label",
]
ICON_NAME = "page_control"
@@ -236,10 +235,6 @@ class BECProgressBar(BECWidget, QWidget):
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
)
def _get_label(self) -> str:
"""Return the label text. mostly used for testing rpc."""
return self.center_label.text()
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -96,6 +96,7 @@ class Ring(BECConnector, QObject):
def __init__(
self,
parent=None,
parent_progress_widget=None,
config: RingConfig | dict | None = None,
client=None,
gui_id: Optional[str] = None,
@@ -110,8 +111,7 @@ class Ring(BECConnector, QObject):
self.config = config
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
self.parent_progress_widget = parent
self.parent_progress_widget = parent_progress_widget
self.color = None
self.background_color = None
self.start_position = None
@@ -244,10 +244,10 @@ class Ring(BECConnector, QObject):
"""
if self.config.connections.endpoint == endpoint and self.config.connections.slot == slot:
return
if self.config.connections.slot is not None:
self.bec_dispatcher.disconnect_slot(
getattr(self, self.config.connections.slot), self.config.connections.endpoint
)
self.bec_dispatcher.disconnect_slot(
self.config.connections.slot, self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint)
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)

View File

@@ -20,7 +20,7 @@ logger = bec_logger.logger
class RingProgressBarConfig(ConnectionConfig):
color_map: Optional[str] = Field(
"plasma", description="Color scheme for the progress bars.", validate_default=True
"magma", description="Color scheme for the progress bars.", validate_default=True
)
min_number_of_bars: int | None = Field(
1, description="Minimum number of progress bars to display."
@@ -123,7 +123,7 @@ class RingProgressBar(BECWidget, QWidget):
# For updating bar behaviour
self._auto_updates = True
self._rings = []
self._rings = None
if num_bars is not None:
self.config.num_bars = max(
@@ -134,12 +134,11 @@ class RingProgressBar(BECWidget, QWidget):
self.enable_auto_updates(self.config.auto_updates)
@property
def rings(self) -> list[Ring]:
"""Returns a list of all rings in the progress bar."""
def rings(self):
return self._rings
@rings.setter
def rings(self, value: list[Ring]):
def rings(self, value):
self._rings = value
def update_config(self, config: RingProgressBarConfig | dict):
@@ -170,15 +169,15 @@ class RingProgressBar(BECWidget, QWidget):
)
for i in range(self.config.num_bars)
]
self._rings = [Ring(parent=self, config=config) for config in self.config.rings]
self._rings = [
Ring(parent_progress_widget=self, config=config) for config in self.config.rings
]
if self.config.color_map:
self.set_colors_from_map(self.config.color_map)
min_size = self._calculate_minimum_size()
self.setMinimumSize(min_size)
# Set outer ring to listen to scan progress
self.rings[0].set_update(mode="scan")
self.update()
def add_ring(self, **kwargs) -> Ring:
@@ -200,7 +199,7 @@ class RingProgressBar(BECWidget, QWidget):
directions=-1,
**kwargs,
)
ring = Ring(parent=self, config=ring_config)
ring = Ring(parent_progress_widget=self, config=ring_config)
self.config.num_bars += 1
self._rings.append(ring)
self.config.rings.append(ring.config)
@@ -219,10 +218,6 @@ class RingProgressBar(BECWidget, QWidget):
index(int): Index of the progress bar to remove.
"""
ring = self._find_ring_by_index(index)
self._cleanup_ring(ring)
self.update()
def _cleanup_ring(self, ring: Ring) -> None:
ring.reset_connection()
self._rings.remove(ring)
self.config.rings.remove(ring.config)
@@ -230,10 +225,8 @@ class RingProgressBar(BECWidget, QWidget):
self._reindex_rings()
if self.config.color_map:
self.set_colors_from_map(self.config.color_map)
# Remove ring from rpc, afterwards call close event.
ring.rpc_register.remove_rpc(ring)
ring.deleteLater()
# del ring
del ring
self.update()
def _reindex_rings(self):
"""
@@ -299,7 +292,7 @@ class RingProgressBar(BECWidget, QWidget):
widget_class="Ring", index=i, start_positions=90 * 16, directions=-1
)
self.config.rings.append(new_ring_config)
new_ring = Ring(parent=self, config=new_ring_config)
new_ring = Ring(parent_progress_widget=self, config=new_ring_config)
self._rings.append(new_ring)
elif num_bars < current_num_bars:
@@ -649,6 +642,6 @@ class RingProgressBar(BECWidget, QWidget):
self.on_scan_queue_status, MessageEndpoints.scan_queue_status()
)
for ring in self._rings:
self._cleanup_ring(ring)
ring.reset_connection()
self._rings.clear()
super().cleanup()

View File

@@ -57,7 +57,6 @@ class BECQueue(BECWidget, CompactPopupWidget):
# self.layout.addWidget(self.table)
self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status", "Cancel"])
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
@@ -74,16 +73,15 @@ class BECQueue(BECWidget, CompactPopupWidget):
"""
Set the toolbar.
"""
widget_label = QLabel(text="Live Queue", parent=self)
widget_label = QLabel("Live Queue")
widget_label.setStyleSheet("font-weight: bold;")
self.toolbar = ModularToolBar(
parent=self,
actions={
"widget_label": WidgetAction(widget=widget_label),
"separator_1": SeparatorAction(),
"resume": WidgetAction(widget=ResumeButton(parent=self, toolbar=False)),
"stop": WidgetAction(widget=StopButton(parent=self, toolbar=False)),
"reset": WidgetAction(widget=ResetButton(parent=self, toolbar=False)),
"resume": WidgetAction(widget=ResumeButton(toolbar=False)),
"stop": WidgetAction(widget=StopButton(toolbar=False)),
"reset": WidgetAction(widget=ResetButton(toolbar=False)),
},
target_widget=self,
)
@@ -225,7 +223,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
Returns:
AbortButton: The abort button.
"""
abort_button = AbortButton(parent=self, scan_id=scan_id)
abort_button = AbortButton(scan_id=scan_id)
abort_button.button.setText("")
abort_button.button.setIcon(
@@ -233,6 +231,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
)
abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ")
abort_button.button.setFlat(True)
return abort_button
def delete_selected_row(self):
@@ -240,7 +239,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
button = self.sender()
row = self.table.indexAt(button.pos()).row()
self.table.removeRow(row)
button.close()
button.deleteLater()
def reset_content(self):

View File

@@ -16,7 +16,7 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.widgets.services.bec_status_box.status_item import StatusItem
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_lib.client import BECClient
# TODO : Put normal imports back when Pydantic gets faster
@@ -76,7 +76,6 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
PLUGIN = True
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
USER_ACCESS = ["get_server_state", "remove"]
service_update = Signal(BECServiceInfoContainer)
bec_core_state = Signal(str)
@@ -135,10 +134,6 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
"QTreeWidget::item:selected {}"
)
def get_server_state(self) -> str:
"""Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server"""
return self.status_container[self.box_name]["info"].status
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:

View File

@@ -1,4 +1,4 @@
"""Module for a StatusItem widget to display status and metrics for a BEC service.
""" Module for a StatusItem widget to display status and metrics for a BEC service.
The widget is bound to be used with the BECStatusBox widget."""
import enum

View File

@@ -7,7 +7,7 @@ from qtpy.QtCore import QMimeData, Qt
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from qtpy.QtGui import QMouseEvent
logger = bec_logger.logger

View File

@@ -1,4 +1,4 @@
"""Utilities for filtering and formatting in the LogPanel"""
""" Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations

View File

@@ -8,7 +8,7 @@ import re
from collections import deque
from functools import partial, reduce
from re import Pattern
from typing import TYPE_CHECKING, Literal
from typing import Literal
from bec_lib.client import BECClient
from bec_lib.connector import ConnectorBase
@@ -16,7 +16,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from PySide6.QtCore import QObject
from qtpy.QtCore import QDateTime, Qt, Signal
from qtpy.QtCore import QDateTime, Qt, Signal, SignalInstance # type: ignore
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
@@ -52,9 +52,6 @@ from bec_widgets.widgets.utility.logpanel._util import (
simple_color_format,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import SignalInstance
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import sys
from bec_qthemes import material_icon

View File

@@ -1,58 +0,0 @@
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QPushButton
from bec_widgets import BECWidget, SafeProperty, SafeSlot
class ColorButtonNative(BECWidget, QPushButton):
"""A QPushButton subclass that displays a color.
The background is set to the given color and the button text is the hex code.
The text color is chosen automatically (black if the background is light, white if dark)
to guarantee good readability.
"""
RPC = False
PLUGIN = True
ICON_NAME = "colors"
def __init__(self, parent=None, color="#000000", **kwargs):
"""Initialize the color button.
Args:
parent: Optional QWidget parent.
color (str): The initial color in hex format (e.g., '#000000').
"""
super().__init__(parent=parent, **kwargs)
self.set_color(color)
@SafeSlot()
def set_color(self, color):
"""Set the button's color and update its appearance.
Args:
color (str or QColor): The new color to assign.
"""
if isinstance(color, QColor):
self._color = color.name()
else:
self._color = color
self._update_appearance()
@SafeProperty("QColor")
def color(self):
"""Return the current color in hex."""
return self._color
@color.setter
def color(self, value):
"""Set the button's color and update its appearance."""
self.set_color(value)
def _update_appearance(self):
"""Update the button style based on the background color's brightness."""
c = QColor(self._color)
brightness = c.lightnessF()
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)

View File

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

View File

@@ -1,56 +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.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
DOM_XML = """
<ui language='c++'>
<widget class='ColorButtonNative' name='color_button_native'>
</widget>
</ui>
"""
class ColorButtonNativePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = ColorButtonNative(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Buttons"
def icon(self):
return designer_material_icon(ColorButtonNative.ICON_NAME)
def includeFile(self):
return "color_button_native"
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 "ColorButtonNative"
def toolTip(self):
return "A QPushButton subclass that displays a color."
def whatsThis(self):
return self.toolTip()

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