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

Compare commits

...

81 Commits

Author SHA1 Message Date
semantic-release
bdf33a5249 2.5.2
Automatically generated by python-semantic-release
2025-05-22 07:07:24 +00:00
f8276f0224 fix: update gitignore 2025-05-22 09:06:43 +02:00
8227c44c33 docs: fix build process for sphinx 2025-05-21 14:21:16 +02:00
semantic-release
83098d930c 2.5.1
Automatically generated by python-semantic-release
2025-05-21 11:14:04 +00:00
a7ae856c8f fix(ui loader): fix loader for widget plugins 2025-05-21 13:13:18 +02:00
Klaus Wakonig
06f43e4883 docs: add kwargs to example 2025-05-21 09:29:24 +02:00
Klaus Wakonig
5ec9697271 docs(developer): fix hello world example 2025-05-21 09:29:24 +02:00
semantic-release
41296b5471 2.5.0
Automatically generated by python-semantic-release
2025-05-20 14:37:27 +00:00
1d018e863c feat(image_rois): image rois with RPC can be added to Image widget 2025-05-20 16:36:48 +02:00
6ee0f5004d ci: try uv for test env setup 2025-05-20 15:05:06 +02:00
semantic-release
40b5081632 2.4.3
Automatically generated by python-semantic-release
2025-05-19 15:25:35 +00:00
f064baae68 fix: twine upload key 2025-05-19 17:24:55 +02:00
semantic-release
58f01fb3a2 2.4.2
Automatically generated by python-semantic-release
2025-05-19 15:04:48 +00:00
1e344eacb7 fix: push release using GH_token 2025-05-19 17:04:04 +02:00
semantic-release
34002fa51a 2.4.1
Automatically generated by python-semantic-release
2025-05-19 14:34:58 +00:00
a00d510a75 fix: skip actions on new tags 2025-05-19 16:34:19 +02:00
semantic-release
120faf9523 2.4.0
Automatically generated by python-semantic-release
2025-05-19 13:53:08 +00:00
d7bd61f69e ci: use custom semver action 2025-05-19 15:50:24 +02:00
94bcfff724 ci: add known hosts 2025-05-19 15:10:38 +02:00
a17e7a0d52 ci: add deploy ssh key to release job 2025-05-19 15:02:54 +02:00
7f67d28887 ci: use ssh key for push 2025-05-19 14:39:01 +02:00
52d8e4b332 ci: build with ssh key 2025-05-19 14:26:17 +02:00
dea2b44e6a ci: fix job permissions for release 2025-05-19 13:47:25 +02:00
dc70ea6dfb ci: fix missing build dependencies 2025-05-19 13:32:16 +02:00
133ddda3e3 ci: fix missing build dependencies 2025-05-19 13:16:29 +02:00
8eee92e5cf ci: add semantic-release job 2025-05-19 12:57:17 +02:00
Klaus Wakonig
85de24aa89 chore: update issue templates 2025-05-17 20:38:32 +02:00
56b6a0b8c2 feat: add web console 2025-05-17 13:34:21 +02:00
d579d894f0 feat(modular_toolbar): remove action/bundle by id 2025-05-17 09:55:00 +02:00
d915d2f507 fix: (#612) fix additional MD form
makes sure the form is validated on any changes of the additional
metadata table model so that they are propagated to the scan control
widget even when nothing is entered in the standard form
2025-05-16 14:37:07 +02:00
7d7a88669f fix: (#572) signal input base filter
use name attribute rather than value from Kind, to compare with kind_str
2025-05-16 10:50:27 +02:00
a42dcec6d4 fix(entry_validator): device signals retrieved from ._info instead of .describe(), close #570 2025-05-15 15:33:00 +02:00
8cf1f09926 ci: exclude test dir from coverage report 2025-05-15 11:35:45 +02:00
83b153a14a ci: include lines with >=3 characters in report 2025-05-15 09:52:37 +02:00
aed450ef2c fix(side_panel): side panel can be open without icon; toolbar can be hidden if not needed 2025-05-15 08:20:19 +02:00
e60d0cb5ca ci: add generate-cli test 2025-05-14 23:37:15 +02:00
01870f9cda test: coverage report settings 2025-05-14 17:43:39 +02:00
483886495d ci: tidy workflow names 2025-05-14 17:43:39 +02:00
42502f6eed ci: only run tests if formatter passes 2025-05-14 17:43:39 +02:00
59d87e1c2f ci: no cov report with failed tests 2025-05-14 17:43:39 +02:00
Klaus Wakonig
3a5fa3d01a chore: update license 2025-05-14 16:36:03 +02:00
dbb3a1c1fb fix(workflows): update ophyd_devices clone URL to use GitHub 2025-05-14 16:15:06 +02:00
ca8211572f ci(workflows): update git clone URL for BEC repository to use GitHub 2025-05-14 15:41:12 +02:00
7584af4e44 ci: don't duplicate push & PR 2025-05-14 15:08:47 +02:00
95ef26565b ci: add codecov upload
and remove other coverage solution
2025-05-14 15:08:47 +02:00
abbf7a7f44 fix(device_input): remove unnecessary lowercase conversion for device selection 2025-05-14 11:58:41 +02:00
a301d37c4f ci: coverage 2025-05-13 19:29:11 +02:00
88a17a566c fix(layout_manager): adding relative widget is shifting whole column to not destroy previous layout 2025-05-13 11:33:16 +02:00
bf3746da0e refactor(color_button_native): color button with OS native dialog separated from the curve tree 2025-05-12 18:24:46 +02:00
e3205d6c97 ci: fix upload to codecov 2025-05-12 18:23:23 +02:00
Klaus Wakonig
507ac10e8d ci: add links to badges
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-12 18:23:23 +02:00
16e167019f ci: add coverage report 2025-05-12 18:23:23 +02:00
d712944e6b docs: badges extravaganza 2025-05-12 18:23:23 +02:00
d9b60c6cc9 docs: fix license reference 2025-05-12 15:37:10 +02:00
aee83e1a9e docs: add badge for code style, version and license 2025-05-12 15:37:10 +02:00
f5317341bf ci: add ci status badge 2025-05-12 15:37:10 +02:00
8345dacb26 ci: add github workflows 2025-05-12 13:44:37 +02:00
semantic-release
531d9c621d 2.3.0
Automatically generated by python-semantic-release
2025-05-09 12:36:13 +00:00
dc151cdfe3 feat(bec_connector): ability to change object name during runtime 2025-05-09 14:27:44 +02:00
semantic-release
e0dfd56a0d 2.2.0
Automatically generated by python-semantic-release
2025-05-09 09:41:37 +00:00
1fb680abb4 feat(launcher): add support for launching plugin widget 2025-05-08 17:30:16 +02:00
b9e56c96cb refactor(launch_window): widget tile added 2025-05-08 13:50:01 +02:00
semantic-release
dd956f18fe 2.1.3
Automatically generated by python-semantic-release
2025-05-07 14:31:53 +00:00
cf59d31113 fix(bec-dispatcher): fix reference to boundmethods to avoid duplicated subscriptions 2025-05-07 11:08:06 +02:00
semantic-release
bc0e277332 2.1.2
Automatically generated by python-semantic-release
2025-05-06 11:09:41 +00:00
75a2780fe0 tests(user-interaction-e2e): add module scoped e2e tests with user interaction; closes #508 2025-05-06 11:28:12 +02:00
a6c479e42e build: remove flush-redis from ci job 2025-05-06 11:28:12 +02:00
64a4824054 fix(waveform): Ignore callbacks for on_async_readback from QtSender objects that are already destroyed; closes #497 2025-05-06 11:28:12 +02:00
1619446ec9 refactor(bec-status-box): add get_server_state user_access method to BECStatusBox 2025-05-06 11:28:12 +02:00
37f002427a refactor(bec-progressbar): add private method for bec_progressbar, udate client file 2025-05-06 11:28:12 +02:00
semantic-release
50cb70dcc6 2.1.1
Automatically generated by python-semantic-release
2025-05-06 08:37:48 +00:00
55f7efc4f5 fix: import add operator in client 2025-05-06 10:20:47 +02:00
be72c9f270 refactor: supply bec designer filename to function 2025-05-06 10:20:47 +02:00
c8cedc0124 wip 2025-05-06 08:54:36 +02:00
semantic-release
3fdbe4031e 2.1.0
Automatically generated by python-semantic-release
2025-05-05 11:10:40 +00:00
c16b9dce9c test(Dock): add validation for new dock creation with invalid name 2025-05-05 13:01:21 +02:00
9387275851 feat(SafeSlot): slot parameters can be overridden with kwarg; add option to raise 2025-05-05 13:01:21 +02:00
94463afdba fix: ensure rpc object do not collide with protected names 2025-05-05 13:01:21 +02:00
02563b10f3 refactor(colormap_widget): widget is rounded 2025-05-02 16:01:51 +02:00
fff4af2489 ci: install dev dependencies for formatter 2025-05-02 14:12:18 +02:00
452124b528 chore(formatter): upgrade to black v25 2025-05-02 14:12:18 +02:00
90 changed files with 6270 additions and 1851 deletions

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,26 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]

View File

@@ -0,0 +1,48 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting]
## Problem Description
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
## Use Case
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
## Proposed Solution
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
## Benefits
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
## Alternatives Considered
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
## Impact on Existing Functionality
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
## Priority
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
## Attachments
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
## Additional Information
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]

28
.github/workflows/check_pr.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
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' && github.event.ref_type != 'tag'
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"]
}

36
.github/workflows/ci.yml vendored Normal file
View File

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

48
.github/workflows/end2end-conda.yml vendored Normal file
View File

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

61
.github/workflows/formatter.yml vendored Normal file
View File

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

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

49
.github/workflows/pytest-matrix.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
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 uv
uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -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

65
.github/workflows/pytest.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
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 uv
uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev]
uv pip install --system -e ./bec/bec_ipython_client
uv pip install --system -e .[dev,pyside6]
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets

103
.github/workflows/semantic_release.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Continuous Delivery
on:
push:
branches:
- main
# default: least privileged permissions across all jobs
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-release-${{ github.ref_name }}
cancel-in-progress: false
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"
permissions:
contents: write
steps:
# Note: We checkout the repository at the branch that triggered the workflow
# with the entire history to ensure to match PSR's release branch detection
# and history evaluation.
# However, we forcefully reset the branch to the workflow sha because it is
# possible that the branch was updated while the workflow was running. This
# prevents accidentally releasing un-evaluated changes.
- name: Setup | Checkout Repository on Release Branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup | Force release branch to be at workflow sha
run: |
git reset --hard ${{ github.sha }}
- name: Evaluate | Verify upstream has NOT changed
# Last chance to abort before causing an error as another PR/push was applied to
# the upstream branch while this workflow was running. This is important
# because we are committing a version change (--commit). You may omit this step
# if you have 'commit: false' in your configuration.
#
# You may consider moving this to a repo script and call it from this step instead
# of writing it in-line.
shell: bash
run: |
set +o pipefail
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
set -o pipefail
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
exit 1
fi
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
exit 1
fi
HEAD_SHA="$(git rev-parse HEAD)"
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
exit 1
fi
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
- name: Semantic Version Release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pip install python-semantic-release==9.* wheel build twine
semantic-release -vv version
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
semantic-release publish

3
.gitignore vendored
View File

@@ -64,6 +64,9 @@ coverage.xml
.pytest_cache/
cover/
# Output from end2end testing
tests/reference_failures/
# Translations
*.mo
*.pot

View File

@@ -77,7 +77,7 @@ formatter:
stage: Formatter
needs: []
script:
- pip install bec_lib[dev]
- pip install -e ./[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
@@ -203,7 +203,7 @@ test-matrix:
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3
image: continuumio/miniconda3:25.1.1-2
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
@@ -230,7 +230,7 @@ end-2-end-conda:
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
artifacts:
when: on_failure

View File

@@ -7,13 +7,13 @@ version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-20.04
os: ubuntu-22.04
tools:
python: "3.10"
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
configuration: docs/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF
# formats:
@@ -21,5 +21,7 @@ sphinx:
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
install:
- requirements: docs/requirements.txt
- method: pip
path: .[dev]

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,16 @@
# 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

@@ -2,10 +2,10 @@ from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
@@ -21,8 +21,10 @@ 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
@@ -35,6 +37,8 @@ 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__)
@@ -141,6 +145,7 @@ 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)
@@ -156,58 +161,105 @@ class LaunchWindow(BECMainWindow):
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.tile_dock_area = LaunchTile(
self.register_tile(
name="dock_area",
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.tile_auto_update = LaunchTile(
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
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.tile_ui_file = LaunchTile(
self.register_tile(
name="custom_ui_file",
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)
# 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()
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
# 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()),
)
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,
)
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
def launch(
self,
launch_script: str,
@@ -235,10 +287,8 @@ class LaunchWindow(BECMainWindow):
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name {name} contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
@@ -258,6 +308,12 @@ class LaunchWindow(BECMainWindow):
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.")
@@ -275,6 +331,7 @@ class LaunchWindow(BECMainWindow):
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
@@ -284,6 +341,8 @@ 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
@@ -321,11 +380,28 @@ 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:
for tile in self.tiles.values():
tile.apply_theme(theme)
super().apply_theme(theme)
@@ -334,14 +410,25 @@ class LaunchWindow(BECMainWindow):
"""
Open the auto update window.
"""
if self.tile_auto_update.selector is None:
if self.tiles["auto_update"].selector is None:
auto_update = None
else:
auto_update = self.tile_auto_update.selector.currentText()
auto_update = self.tiles["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):
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -7,6 +7,7 @@ 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
@@ -54,6 +55,7 @@ _Widgets = {
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
"WebConsole": "WebConsole",
"WebsiteWidget": "WebsiteWidget",
}
@@ -469,6 +471,12 @@ 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."""
@@ -483,6 +491,12 @@ 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):
"""
@@ -490,6 +504,204 @@ class BECStatusBox(RPCBase):
"""
class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self):
"""
Gets the coordinates that define this ROI's position and shape.
This is an abstract method that must be implemented by subclasses.
Implementations should return either a dictionary with descriptive keys
or a tuple of coordinates, depending on the value of self.description.
Returns:
dict or tuple: The coordinates defining the ROI's position and shape.
Raises:
NotImplementedError: This method must be implemented by subclasses.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
"""
Calculates and returns the coordinates and size of an object, either as a
typed dictionary or as a tuple.
Args:
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
to None, which utilizes the object's description value.
Returns:
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
if `typed` is True.
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
class Curve(RPCBase):
@rpc_call
def remove(self):
@@ -1201,6 +1413,44 @@ class Image(RPCBase):
Access the main image item.
"""
@rpc_call
def add_roi(
self,
kind: "Literal['rect', 'circle']" = "rect",
name: "str | None" = None,
line_width: "int | None" = 10,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
Add a ROI to the image.
Args:
kind(str): The type of ROI to add. Options are "rect" or "circle".
name(str): The name of the ROI.
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
**pg_kwargs: Additional arguments for the ROI.
Returns:
RectangularROI | CircularROI: The created ROI object.
"""
@rpc_call
def remove_roi(self, roi: "int | str"):
"""
Remove an ROI by index or label via the ROIController.
"""
@property
@rpc_call
def rois(self) -> "list[BaseROI]":
"""
Get the list of ROIs.
"""
class ImageItem(RPCBase):
@property
@@ -2304,6 +2554,105 @@ class PositionerGroup(RPCBase):
"""
class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
@property
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@label.setter
@rpc_call
def label(self) -> "str":
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
@property
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@line_color.setter
@rpc_call
def line_color(self) -> "str":
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
@property
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@line_width.setter
@rpc_call
def line_width(self) -> "int":
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
@rpc_call
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
"""
Returns the coordinates of a rectangle's corners. Supports returning them
as either a dictionary with descriptive keys or a tuple of coordinates.
Args:
typed (bool | None): If True, returns coordinates as a dictionary with
descriptive keys. If False, returns them as a tuple. Defaults to
the value of `self.description`.
Returns:
dict | tuple: The rectangle's corner coordinates, where the format
depends on the `typed` parameter.
"""
@rpc_call
def get_data_from_image(
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
):
"""
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
class ResetButton(RPCBase):
"""A button that resets the scan queue."""
@@ -3488,6 +3837,16 @@ class Waveform(RPCBase):
"""
class WebConsole(RPCBase):
"""A simple widget to display a website"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class WebsiteWidget(RPCBase):
"""A simple widget to display a website"""

View File

@@ -41,6 +41,7 @@ class ClientGenerator:
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base

View File

@@ -43,7 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"pg": pg,
"wh": wh,
"dock": self.dock,
# "im": self.im,
"im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
@@ -112,13 +112,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# 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)
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=False)
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)

View File

@@ -96,9 +96,9 @@ class FakePositioner(BECPositioner):
}
self._info = {
"signals": {
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
}
}
self.signals = {

View File

@@ -205,6 +205,17 @@ class BECConnector:
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:
"""
Enforce a unique object name among siblings and register the object for RPC.

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(): # pragma: no cover
def patch_designer(cmd_args: list[str] = []): # 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(): # 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"), sys.argv[1:])
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
def find_plugin_paths(base_path: Path):
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
# Patch the designer function
def main(): # pragma: no cover
def open_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
@@ -160,7 +160,11 @@ def main(): # pragma: no cover
set_plugin_environment_variable(plugin_paths)
patch_designer()
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
if __name__ == "__main__": # pragma: no cover

View File

@@ -4,8 +4,9 @@ import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
@@ -25,21 +26,41 @@ if TYPE_CHECKING: # pragma: no cover
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):
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.
"""
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 id(self.cb)
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
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -86,7 +107,7 @@ class BECDispatcher:
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
gui_id: str | None = None,
*args,
**kwargs,
):
@@ -99,7 +120,9 @@ class BECDispatcher:
if self._initialized:
return
self._slots = collections.defaultdict(set)
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if self.client is None:
@@ -141,6 +164,7 @@ 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.
@@ -149,11 +173,15 @@ 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.
"""
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot, **kwargs)
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)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
qt_slot.topics.update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
@@ -166,16 +194,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._slots:
for connected_slot in self._registered_slots.values():
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._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
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]
def disconnect_topics(self, topics: Union[str, list]):
"""
@@ -186,11 +214,16 @@ class BECDispatcher:
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
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]
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)
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

@@ -1,11 +1,10 @@
from __future__ import annotations
import itertools
from typing import Literal, Type
from typing import Any, Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.cli.client_utils import BECGuiClient
class WidgetContainerUtils:
@@ -73,3 +72,36 @@ 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

@@ -17,13 +17,23 @@ class EntryValidator:
raise ValueError(f"Device '{name}' not found in current BEC session")
device = self.devices[name]
description = device.describe()
# Build list of available signal entries from device._info['signals']
signals_dict = getattr(device, "_info", {}).get("signals", {})
available_entries = [
sig.get("obj_name") for sig in signals_dict.values() if sig.get("obj_name")
]
# If no signals are found, means device is a signal, use the device name as the entry
if not available_entries:
available_entries = [name]
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
if entry not in available_entries:
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
f"Entry '{entry}' not found in device '{name}' signals. "
f"Available signals: '{available_entries}'"
)
return entry

View File

@@ -99,16 +99,30 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
'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.
"""
popup_error = bool(slot_kwargs.pop("popup_error", False))
verify_sender = bool(slot_kwargs.pop("verify_sender", False))
_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)),
}
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 verify_sender or len(args) == 0:
if not _slot_params["verify_sender"] or len(args) == 0:
return method(*args, **kwargs)
_instance = args[0]
@@ -126,11 +140,11 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
if _slot_params["popup_error"]:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
if _slot_params["raise_error"]:
raise
return wrapper

View File

@@ -1,5 +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. """
"""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

View File

@@ -31,6 +31,7 @@ class SidePanel(QWidget):
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
show_toolbar: bool = True,
):
super().__init__(parent=parent)
@@ -40,6 +41,7 @@ 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
@@ -71,13 +73,14 @@ class SidePanel(QWidget):
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
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.container)
else:
self.main_layout.insertWidget(0, self.container)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
@@ -102,11 +105,13 @@ class SidePanel(QWidget):
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
self.main_layout.addWidget(self.toolbar)
if self._show_toolbar:
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._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
@@ -233,21 +238,24 @@ 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.
title(str): The title of 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().
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
@@ -278,32 +286,35 @@ class SidePanel(QWidget):
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
# 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)
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)
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
else:
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
action.action.toggled.connect(on_action_toggled)
return index
############################################
@@ -332,41 +343,56 @@ 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

@@ -702,6 +702,85 @@ class ModularToolBar(QToolBar):
self.bundles[bundle_id].append(action_id)
self.update_separators()
def remove_action(self, action_id: str):
"""
Completely remove a single action from the toolbar.
The method takes care of both standalone actions and actions that are
part of an existing bundle.
Args:
action_id (str): Unique identifier for the action.
"""
if action_id not in self.widgets:
raise ValueError(f"Action with ID '{action_id}' does not exist.")
# Identify potential bundle membership
parent_bundle = None
for b_id, a_ids in self.bundles.items():
if action_id in a_ids:
parent_bundle = b_id
break
# 1. Remove the QAction from the QToolBar and delete it
tool_action = self.widgets.pop(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
self.removeAction(tool_action.action)
tool_action.action.deleteLater()
# 2. Clean bundle bookkeeping if the action belonged to one
if parent_bundle:
self.bundles[parent_bundle].remove(action_id)
# If the bundle becomes empty, get rid of the bundle entry as well
if not self.bundles[parent_bundle]:
self.remove_bundle(parent_bundle)
# 3. Remove from the ordering list
self.toolbar_items = [
item
for item in self.toolbar_items
if not (item[0] == "action" and item[1] == action_id)
]
self.update_separators()
def remove_bundle(self, bundle_id: str):
"""
Remove an entire bundle (and all of its actions) from the toolbar.
Args:
bundle_id (str): Unique identifier for the bundle.
"""
if bundle_id not in self.bundles:
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
# Remove every action belonging to this bundle
for action_id in list(self.bundles[bundle_id]): # copy the list
if action_id in self.widgets:
tool_action = self.widgets.pop(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
self.removeAction(tool_action.action)
tool_action.action.deleteLater()
# Drop the bundle entry
self.bundles.pop(bundle_id, None)
# Remove bundle entry and its preceding separator (if any) from the ordering list
cleaned_items = []
skip_next_separator = False
for item_type, ident in self.toolbar_items:
if item_type == "bundle" and ident == bundle_id:
# mark to skip one following separator if present
skip_next_separator = True
continue
if skip_next_separator and item_type == "separator":
skip_next_separator = False
continue
cleaned_items.append((item_type, ident))
self.toolbar_items = cleaned_items
self.update_separators()
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show toolbar actions with checkboxes and icons.

View File

@@ -2,6 +2,7 @@ from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
@@ -34,6 +35,9 @@ class UILoader:
self.custom_widgets = {widget.__name__: widget for widget in widgets}
plugin_widgets = get_all_plugin_widgets()
self.custom_widgets.update(plugin_widgets)
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:

View File

@@ -303,11 +303,7 @@ class BECDock(BECWidget, Dock):
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name {name} contains invalid characters. "
f"Only alphanumeric characters and underscores are allowed."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
row = self.layout.rowCount()

View File

@@ -366,11 +366,8 @@ 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}."
)
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name {name} contains invalid characters. "
f"Only alphanumeric characters and underscores are allowed."
)
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)

View File

@@ -53,7 +53,7 @@ class LayoutManagerWidget(QWidget):
self,
widget: QWidget | str,
row: int | None = None,
col: Optional[int] = None,
col: int | None = None,
rowspan: int = 1,
colspan: int = 1,
shift_existing: bool = True,
@@ -138,6 +138,39 @@ 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

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

@@ -397,7 +397,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.lower(), None)
dev = getattr(self.dev, device, None)
if dev is None:
raise ValueError(
f"Device {device} is not found in the device manager {self.dev} as enabled device."

View File

@@ -36,14 +36,16 @@ class DeviceSignalInputBase(BECWidget):
Kind.config: "include_config_signals",
}
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
if config is None:
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceSignalInputBaseConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
def __init__(
self,
client=None,
config: DeviceSignalInputBaseConfig | dict | None = None,
gui_id: str = None,
**kwargs,
):
self.config = self._process_config_input(config)
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
self._device = None
self.get_bec_shortcuts()
@@ -102,10 +104,7 @@ class DeviceSignalInputBase(BECWidget):
"""
self.config.signal_filter = self.signal_filter
# pylint: disable=protected-access
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
if self.validate_device(self._device) is False:
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
@@ -116,27 +115,19 @@ class DeviceSignalInputBase(BECWidget):
FilterIO.set_selection(widget=self, selection=[self._device])
return
device_info = device._info["signals"]
if Kind.hinted in self.signal_filter:
hinted_signals = [
def _update(kind: Kind):
return [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
self._hinted_signals = hinted_signals
if Kind.normal in self.signal_filter:
normal_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
]
self._normal_signals = normal_signals
if Kind.config in self.signal_filter:
config_signals = [
signal
for signal, signal_info in device_info.items()
if (signal_info.get("kind_str", None) == str(Kind.config.value))
]
self._config_signals = config_signals
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
self._config_signals = _update(Kind.config)
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
FilterIO.set_selection(widget=self, selection=self.signals)
@@ -250,7 +241,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.lower(), None)
dev = getattr(self.dev, device, None)
if dev is None:
logger.warning(f"Device {device} not found in devicemanager.")
return None
@@ -279,3 +270,8 @@ class DeviceSignalInputBase(BECWidget):
if signal in self.signals:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)

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.lower())
self.device_selected.emit(input_text)
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.lower())
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.update()

View File

@@ -1,4 +1,4 @@
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
"""Module for DapComboBox widget class to select a DAP model from a combobox."""
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot

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

@@ -53,6 +53,7 @@ class DictBackedTableModel(QAbstractTableModel):
if value in self._disallowed_keys or value in self._other_keys(index.row()):
return False
self._data[index.row()][index.column()] = str(value)
self.dataChanged.emit(index, index)
return True
return False
@@ -109,6 +110,7 @@ class DictBackedTableModel(QAbstractTableModel):
class DictBackedTable(QWidget):
delete_rows = Signal(list)
data_updated = Signal()
def __init__(self, initial_data: list[list[str]]):
"""Widget which uses a DictBackedTableModel to display an editable table
@@ -141,6 +143,11 @@ class DictBackedTable(QWidget):
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(self._emit_data_updated)
def _emit_data_updated(self, *args, **kwargs):
"""Just to swallow the args"""
self.data_updated.emit()
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""

View File

@@ -43,6 +43,7 @@ class ScanMetadata(PydanticModelForm):
self._additional_metadata = DictBackedTable(initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_updated.connect(self.validate_form)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)

View File

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

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import secrets
import subprocess
import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class WebConsoleRegistry:
"""
A registry for the WebConsole class to manage its instances.
"""
def __init__(self):
"""
Initialize the registry.
"""
self._instances = {}
self._server_process = None
self._server_port = None
self._token = secrets.token_hex(16)
def register(self, instance: WebConsole):
"""
Register an instance of WebConsole.
"""
self._instances[instance.gui_id] = safe_ref(instance)
self.cleanup()
if self._server_process is None:
# Start the ttyd server if not already running
self.start_ttyd()
def start_ttyd(self, use_zsh: bool | None = None):
"""
Start the ttyd server
ttyd -q -W -t 'theme={"background": "black"}' zsh
Args:
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
"""
# First, check if ttyd is installed
try:
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
except FileNotFoundError:
# pylint: disable=raise-missing-from
raise RuntimeError("ttyd is not installed. Please install it first.")
if use_zsh is None:
# Check if we can use zsh
try:
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
use_zsh = True
except FileNotFoundError:
use_zsh = False
command = [
"ttyd",
"-p",
"0",
"-W",
"-t",
'theme={"background": "black"}',
"-c",
f"user:{self._token}",
]
if use_zsh:
command.append("zsh")
else:
command.append("bash")
# Start the ttyd server
self._server_process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
self._wait_for_server_port()
self._server_process.stdout.close()
self._server_process.stderr.close()
def _wait_for_server_port(self, timeout: float = 10):
"""
Wait for the ttyd server to start and get the port number.
Args:
timeout (float): The timeout in seconds to wait for the server to start.
"""
start_time = time.time()
while True:
output = self._server_process.stderr.readline()
if output == b"" and self._server_process.poll() is not None:
break
if not output:
continue
output = output.decode("utf-8").strip()
if "Listening on" in output:
# Extract the port number from the output
self._server_port = int(output.split(":")[-1])
logger.info(f"ttyd server started on port {self._server_port}")
break
if time.time() - start_time > timeout:
raise TimeoutError(
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
for gui_id, weak_ref in list(self._instances.items()):
if weak_ref() is None:
del self._instances[gui_id]
if not self._instances and self._server_process:
# If no instances are left, terminate the server process
self._server_process.terminate()
self._server_process = None
self._server_port = None
logger.info("ttyd server terminated")
def unregister(self, instance: WebConsole):
"""
Unregister an instance of WebConsole.
Args:
instance (WebConsole): The instance to unregister.
"""
if instance.gui_id in self._instances:
del self._instances[instance.gui_id]
self.cleanup()
_web_console_registry = WebConsoleRegistry()
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)
class BECWebEnginePage(QWebEnginePage):
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
class WebConsole(BECWidget, QWidget):
"""
A simple widget to display a website
"""
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.browser = QWebEngineView(self)
self.page = BECWebEnginePage(self)
self.page.authenticationRequired.connect(self._authenticate)
self.browser.setPage(self.page)
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
def write(self, data: str, send_return: bool = True):
"""
Send data to the web page
"""
self.page.runJavaScript(f"window.term.paste('{data}');")
if send_return:
self.send_return()
def _authenticate(self, _, auth):
"""
Authenticate the request with the provided username and password.
"""
auth.setUser("user")
auth.setPassword(self._token)
def send_return(self):
"""
Send return to the web page
"""
self.page.runJavaScript(
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
)
def send_ctrl_c(self):
"""
Send Ctrl+C to the web page
"""
self.page.runJavaScript(
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
_web_console_registry.unregister(self)
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
widget = WebConsole()
widget.show()
sys.exit(app.exec_())

View File

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

View File

@@ -0,0 +1,54 @@
# 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.editors.web_console.web_console import WebConsole
DOM_XML = """
<ui language='c++'>
<widget class='WebConsole' name='web_console'>
</widget>
</ui>
"""
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = WebConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Console"
def icon(self):
return designer_material_icon(WebConsole.ICON_NAME)
def includeFile(self):
return "web_console"
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 "WebConsole"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -20,6 +20,12 @@ from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
)
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
RectangularROI,
ROIController,
)
logger = bec_logger.logger
@@ -111,6 +117,9 @@ class Image(PlotBase):
"transpose.setter",
"image",
"main_image",
"add_roi",
"remove_roi",
"rois",
]
sync_colorbar_with_autorange = Signal()
@@ -128,6 +137,7 @@ class Image(PlotBase):
self.gui_id = config.gui_id
self._color_bar = None
self._main_image = ImageItem()
self.roi_controller = ROIController(colormap="viridis")
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
@@ -139,6 +149,9 @@ class Image(PlotBase):
# Default Color map to plasma
self.color_map = "plasma"
# Headless controller keeps the canonical list.
self._roi_manager_dialog = None
################################################################################
# Widget Specific GUI interactions
################################################################################
@@ -304,9 +317,81 @@ class Image(PlotBase):
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
################################################################################
# Static rois with roi manager
def add_roi(
self,
kind: Literal["rect", "circle"] = "rect",
name: str | None = None,
line_width: int | None = 10,
pos: tuple[float, float] | None = (10, 10),
size: tuple[float, float] | None = (50, 50),
**pg_kwargs,
) -> RectangularROI | CircularROI:
"""
Add a ROI to the image.
Args:
kind(str): The type of ROI to add. Options are "rect" or "circle".
name(str): The name of the ROI.
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
**pg_kwargs: Additional arguments for the ROI.
Returns:
RectangularROI | CircularROI: The created ROI object.
"""
if name is None:
name = f"ROI_{len(self.roi_controller.rois) + 1}"
if kind == "rect":
roi = RectangularROI(
pos=pos,
size=size,
parent_image=self,
line_width=line_width,
label=name,
**pg_kwargs,
)
elif kind == "circle":
roi = CircularROI(
pos=pos,
size=size,
parent_image=self,
line_width=line_width,
label=name,
**pg_kwargs,
)
else:
raise ValueError("kind must be 'rect' or 'circle'")
# Add to plot and controller (controller assigns color)
self.plot_item.addItem(roi)
self.roi_controller.add_roi(roi)
return roi
def remove_roi(self, roi: int | str):
"""Remove an ROI by index or label via the ROIController."""
if isinstance(roi, int):
self.roi_controller.remove_roi_by_index(roi)
elif isinstance(roi, str):
self.roi_controller.remove_roi_by_name(roi)
else:
raise ValueError("roi must be an int index or str name")
################################################################################
# Widget Specific Properties
################################################################################
################################################################################
# Rois
@property
def rois(self) -> list[BaseROI]:
"""
Get the list of ROIs.
"""
return self.roi_controller.rois
################################################################################
# Colorbar toggle
@@ -925,6 +1010,11 @@ class Image(PlotBase):
"""
Disconnect the image update signals and clean up the image.
"""
# Remove all ROIs
rois = self.rois
for roi in rois:
roi.remove()
# Main Image cleanup
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)

View File

@@ -0,0 +1,867 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pyqtgraph as pg
from pyqtgraph import TextItem
from pyqtgraph import functions as fn
from pyqtgraph import mkPen
from qtpy import QtCore
from qtpy.QtCore import QObject, Signal
from bec_widgets import SafeProperty
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image
class LabelAdorner:
"""Manages a TextItem label on top of any ROI, keeping it aligned."""
def __init__(
self,
roi: BaseROI,
anchor: tuple[int, int] = (0, 1),
padding: int = 2,
bg_color: str | tuple[int, int, int, int] = (0, 0, 0, 100),
text_color: str | tuple[int, int, int, int] = "white",
):
"""
Initializes a label overlay for a given region of interest (ROI), allowing for customization
of text placement, padding, background color, and text color. Automatically attaches the label
to the ROI and updates its position and content based on ROI changes.
Args:
roi: The region of interest to which the label will be attached.
anchor: Tuple specifying the label's anchor relative to the ROI. Default is (0, 1).
padding: Integer specifying the padding around the label's text. Default is 2.
bg_color: RGBA tuple for the label's background color. Default is (0, 0, 0, 100).
text_color: String specifying the color of the label's text. Default is "white".
"""
self.roi = roi
self.label = TextItem(anchor=anchor)
self.padding = padding
self.bg_rgba = bg_color
self.text_color = text_color
roi.addItem(self.label) if hasattr(roi, "addItem") else self.label.setParentItem(roi)
# initial draw
self._update_html(roi.label)
self._reposition()
# reconnect on geometry/name changes
roi.sigRegionChanged.connect(self._reposition)
if hasattr(roi, "nameChanged"):
roi.nameChanged.connect(self._update_html)
def _update_html(self, text: str):
"""
Updates the HTML content of the label with the given text.
Creates an HTML div with the configured background color, text color, and padding,
then sets this HTML as the content of the label.
Args:
text (str): The text to display in the label.
"""
html = (
f'<div style="background: rgba{self.bg_rgba}; '
f"font-weight:bold; color:{self.text_color}; "
f'padding:{self.padding}px;">{text}</div>'
)
self.label.setHtml(html)
def _reposition(self):
"""
Repositions the label to align with the ROI's current position.
This method is called whenever the ROI's position or size changes.
It places the label at the bottom-left corner of the ROI's bounding rectangle.
"""
# put at top-left corner of ROIs bounding rect
size = self.roi.state["size"]
height = size[1]
self.label.setPos(0, height)
class BaseROI(BECConnector):
"""Base class for all Region of Interest (ROI) implementations.
This class serves as a mixin that provides common properties and methods for ROIs,
including name, line color, and line width properties. It inherits from BECConnector
to enable remote procedure call functionality.
Attributes:
RPC (bool): Flag indicating if remote procedure calls are enabled.
PLUGIN (bool): Flag indicating if this class is a plugin.
nameChanged (Signal): Signal emitted when the ROI name changes.
penChanged (Signal): Signal emitted when the ROI pen (color/width) changes.
USER_ACCESS (list): List of methods and properties accessible via RPC.
"""
RPC = True
PLUGIN = False
nameChanged = Signal(str)
penChanged = Signal()
USER_ACCESS = [
"label",
"label.setter",
"line_color",
"line_color.setter",
"line_width",
"line_width.setter",
"get_coordinates",
"get_data_from_image",
]
def __init__(
self,
*,
# BECConnector kwargs
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None,
# ROI-specific
label: str | None = None,
line_color: str | None = None,
line_width: int = 10,
# all remaining pg.*ROI kwargs (pos, size, pen, …)
**pg_kwargs,
):
"""Base class for all modular ROIs.
Args:
label (str): Human-readable name shown in ROI Manager and labels.
line_color (str | None, optional): Initial pen color. Defaults to None.
Controller may override color later.
line_width (int, optional): Initial pen width. Defaults to 15.
Controller may override width later.
config (ConnectionConfig | None, optional): Standard BECConnector argument. Defaults to None.
gui_id (str | None, optional): Standard BECConnector argument. Defaults to None.
parent_image (BECConnector | None, optional): Standard BECConnector argument. Defaults to None.
"""
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
self.config = config
self.set_parent(parent_image)
self.parent_plot_item = parent_image.plot_item
object_name = label.replace("-", "_").replace(" ", "_") if label else None
super().__init__(
object_name=object_name, config=config, gui_id=gui_id, removable=True, **pg_kwargs
)
self._label = label or "ROI"
self._line_color = line_color or "#ffffff"
self._line_width = line_width
self._description = True
self.setPen(mkPen(self._line_color, width=self._line_width))
def set_parent(self, parent: Image):
"""
Sets the parent image for this ROI.
Args:
parent (Image): The parent image object to associate with this ROI.
"""
self.parent_image = parent
def parent(self):
"""
Gets the parent image associated with this ROI.
Returns:
Image: The parent image object, or None if no parent is set.
"""
return self.parent_image
@property
def label(self) -> str:
"""
Gets the display name of this ROI.
Returns:
str: The current name of the ROI.
"""
return self._label
@label.setter
def label(self, new: str):
"""
Sets the display name of this ROI.
If the new name is different from the current name, this method updates
the internal name, emits the nameChanged signal, and updates the object name.
Args:
new (str): The new name to set for the ROI.
"""
if new != self._label:
self._label = new
self.nameChanged.emit(new)
self.change_object_name(new)
@property
def line_color(self) -> str:
"""
Gets the current line color of the ROI.
Returns:
str: The current line color as a string (e.g., hex color code).
"""
return self._line_color
@line_color.setter
def line_color(self, value: str):
"""
Sets the line color of the ROI.
If the new color is different from the current color, this method updates
the internal color value, updates the pen while preserving the line width,
and emits the penChanged signal.
Args:
value (str): The new color to set for the ROI's outline (e.g., hex color code).
"""
if value != self._line_color:
self._line_color = value
# update pen but preserve width
self.setPen(mkPen(value, width=self._line_width))
self.penChanged.emit()
@property
def line_width(self) -> int:
"""
Gets the current line width of the ROI.
Returns:
int: The current line width in pixels.
"""
return self._line_width
@line_width.setter
def line_width(self, value: int):
"""
Sets the line width of the ROI.
If the new width is different from the current width and is greater than 0,
this method updates the internal width value, updates the pen while preserving
the line color, and emits the penChanged signal.
Args:
value (int): The new width to set for the ROI's outline in pixels.
Must be greater than 0.
"""
if value != self._line_width and value > 0:
self._line_width = value
self.setPen(mkPen(self._line_color, width=value))
self.penChanged.emit()
@property
def description(self) -> bool:
"""
Gets whether ROI coordinates should be emitted with descriptive keys by default.
Returns:
bool: True if coordinates should include descriptive keys, False otherwise.
"""
return self._description
@description.setter
def description(self, value: bool):
"""
Sets whether ROI coordinates should be emitted with descriptive keys by default.
This affects the default behavior of the get_coordinates method.
Args:
value (bool): True to emit coordinates with descriptive keys, False to emit
as a simple tuple of values.
"""
self._description = value
def get_coordinates(self):
"""
Gets the coordinates that define this ROI's position and shape.
This is an abstract method that must be implemented by subclasses.
Implementations should return either a dictionary with descriptive keys
or a tuple of coordinates, depending on the value of self.description.
Returns:
dict or tuple: The coordinates defining the ROI's position and shape.
Raises:
NotImplementedError: This method must be implemented by subclasses.
"""
raise NotImplementedError("Subclasses must implement get_coordinates()")
def get_data_from_image(
self, image_item: pg.ImageItem | None = None, returnMappedCoords: bool = False, **kwargs
):
"""Wrapper around `pyqtgraph.ROI.getArrayRegion`.
Args:
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
the first `ImageItem` in the same GraphicsScene as this ROI.
returnMappedCoords (bool): If True, also returns the coordinate array generated by
*getArrayRegion*.
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
such as `axes`, `order`, `shape`, etc.
Returns:
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
"""
if image_item is None:
image_item = next(
(
it
for it in self.scene().items()
if isinstance(it, pg.ImageItem) and it.image is not None
),
None,
)
if image_item is None:
raise RuntimeError("No ImageItem found in the current scene.")
data = image_item.image # the raw ndarray held by ImageItem
return self.getArrayRegion(
data, img=image_item, returnMappedCoords=returnMappedCoords, **kwargs
)
def add_scale_handle(self):
return
def remove(self):
handles = self.handles
for i in range(len(handles)):
try:
self.removeHandle(0)
except IndexError:
continue
self.rpc_register.remove_rpc(self)
self.parent_image.plot_item.removeItem(self)
if hasattr(self.parent_image, "roi_controller"):
self.parent_image.roi_controller._rois.remove(self)
self.parent_image.roi_controller._rebuild_color_buffer()
class RectangularROI(BaseROI, pg.RectROI):
"""
Defines a rectangular Region of Interest (ROI) with additional functionality.
Provides tools for manipulating and extracting data from rectangular areas on
images, includes support for GUI features and event-driven signaling.
Attributes:
edgesChanged (Signal): Signal emitted when the ROI edges change, providing
the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
edgesReleased (Signal): Signal emitted when the ROI edges are released,
providing the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
"""
edgesChanged = Signal(float, float, float, float)
edgesReleased = Signal(float, float, float, float)
def __init__(
self,
*,
# pg.RectROI kwargs
pos: tuple[float, float],
size: tuple[float, float],
pen=None,
# BECConnector kwargs
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None = None,
# ROI specifics
label: str | None = None,
line_color: str | None = None,
line_width: int = 10,
resize_handles: bool = True,
**extra_pg,
):
"""
Initializes an instance with properties for defining a rectangular ROI with handles,
configurations, and an auto-aligning label. Also connects a signal for region updates.
Args:
pos: Initial position of the ROI.
size: Initial size of the ROI.
pen: Defines the border appearance; can be color or style.
config: Optional configuration details for the connection.
gui_id: Optional identifier for the associated GUI element.
parent_image: Optional parent object the ROI is related to.
label: Optional label for identification within the context.
line_color: Optional color of the ROI outline.
line_width: Width of the ROI's outline in pixels.
parent_plot_item: The plot item this ROI belongs to.
**extra_pg: Additional keyword arguments specific to pg.RectROI.
"""
super().__init__(
config=config,
gui_id=gui_id,
parent_image=parent_image,
label=label,
line_color=line_color,
line_width=line_width,
pos=pos,
size=size,
pen=pen,
**extra_pg,
)
self.sigRegionChanged.connect(self._on_region_changed)
self.adorner = LabelAdorner(roi=self)
if resize_handles:
self.add_scale_handle()
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
self.handleHoverPen = fn.mkPen("lime", width=4)
def add_scale_handle(self):
"""
Add scale handles at every corner and edge of the ROI.
Corner handles are anchored to the centre for two-axis scaling.
Edge handles are anchored to the midpoint of the opposite edge for single-axis scaling.
"""
centre = [0.5, 0.5]
# Corner handles anchored to the centre for two-axis scaling
self.addScaleHandle([0, 0], centre) # topleft
self.addScaleHandle([1, 0], centre) # topright
self.addScaleHandle([0, 1], centre) # bottomleft
self.addScaleHandle([1, 1], centre) # bottomright
# Edge handles anchored to the midpoint of the opposite edge
self.addScaleHandle([0.5, 0], [0.5, 1]) # top edge
self.addScaleHandle([0.5, 1], [0.5, 0]) # bottom edge
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
def _on_region_changed(self):
"""
Handles ROI region change events.
This method is called whenever the ROI's position or size changes.
It calculates the new corner coordinates and emits the edgesChanged signal
with the updated coordinates.
"""
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
viewBox = self.parent_plot_item.vb
viewBox.update()
def mouseDragEvent(self, ev):
"""
Handles mouse drag events on the ROI.
This method extends the parent class implementation to emit the edgesReleased
signal when the mouse drag is finished, providing the final coordinates of the ROI.
Args:
ev: The mouse event object containing information about the drag operation.
"""
super().mouseDragEvent(ev)
if ev.isFinish():
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
Returns the coordinates of a rectangle's corners. Supports returning them
as either a dictionary with descriptive keys or a tuple of coordinates.
Args:
typed (bool | None): If True, returns coordinates as a dictionary with
descriptive keys. If False, returns them as a tuple. Defaults to
the value of `self.description`.
Returns:
dict | tuple: The rectangle's corner coordinates, where the format
depends on the `typed` parameter.
"""
if typed is None:
typed = self.description
x0, y0 = self.pos().x(), self.pos().y()
w, h = self.state["size"]
x1, y1 = x0 + w, y0 + h
if typed:
return {
"bottom_left": (x0, y0),
"bottom_right": (x1, y0),
"top_left": (x0, y1),
"top_right": (x1, y1),
}
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
def _lookup_scene_image(self):
"""
Searches for an image in the current scene.
This helper method iterates through all items in the scene and returns
the first pg.ImageItem that has a non-None image property.
Returns:
numpy.ndarray or None: The image from the first found ImageItem,
or None if no suitable image is found.
"""
for it in self.scene().items():
if isinstance(it, pg.ImageItem) and it.image is not None:
return it.image
return None
class CircularROI(BaseROI, pg.CircleROI):
"""Circular Region of Interest with center/diameter tracking and auto-labeling.
This class extends the BaseROI and pg.CircleROI classes to provide a circular ROI
that emits signals when its center or diameter changes, and includes an auto-aligning
label for visual identification.
Attributes:
centerChanged (Signal): Signal emitted when the ROI center or diameter changes,
providing the new (center_x, center_y, diameter) values.
centerReleased (Signal): Signal emitted when the ROI is released after dragging,
providing the final (center_x, center_y, diameter) values.
"""
centerChanged = Signal(float, float, float)
centerReleased = Signal(float, float, float)
def __init__(
self,
*,
pos,
size,
pen=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
parent_image: Image | None = None,
label: str | None = None,
line_color: str | None = None,
line_width: int = 10,
**extra_pg,
):
"""
Initializes a circular ROI with the specified properties.
Creates a circular ROI at the given position and with the given size,
connects signals for tracking changes, and attaches an auto-aligning label.
Args:
pos: Initial position of the ROI as [x, y].
size: Initial size of the ROI as [diameter, diameter].
pen: Defines the border appearance; can be color or style.
config (ConnectionConfig | None, optional): Configuration for BECConnector. Defaults to None.
gui_id (str | None, optional): Identifier for the GUI element. Defaults to None.
parent_image (BECConnector | None, optional): Parent image object. Defaults to None.
label (str | None, optional): Display name for the ROI. Defaults to None.
line_color (str | None, optional): Color of the ROI outline. Defaults to None.
line_width (int, optional): Width of the ROI outline in pixels. Defaults to 3.
parent_plot_item: The plot item this ROI belongs to.
**extra_pg: Additional keyword arguments for pg.CircleROI.
"""
super().__init__(
config=config,
gui_id=gui_id,
parent_image=parent_image,
label=label,
line_color=line_color,
line_width=line_width,
pos=pos,
size=size,
pen=pen,
**extra_pg,
)
self.sigRegionChanged.connect(self._on_region_changed)
self._adorner = LabelAdorner(self)
def _on_region_changed(self):
"""
Handles ROI region change events.
This method is called whenever the ROI's position or size changes.
It calculates the center coordinates and diameter of the circle and
emits the centerChanged signal with these values.
"""
d = self.state["size"][0]
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
self.centerChanged.emit(cx, cy, d)
viewBox = self.parent_plot_item.getViewBox()
viewBox.update()
def mouseDragEvent(self, ev):
"""
Handles mouse drag events on the ROI.
This method extends the parent class implementation to emit the centerReleased
signal when the mouse drag is finished, providing the final center coordinates
and diameter of the circular ROI.
Args:
ev: The mouse event object containing information about the drag operation.
"""
super().mouseDragEvent(ev)
if ev.isFinish():
d = self.state["size"][0]
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
self.centerReleased.emit(cx, cy, d)
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
"""
Calculates and returns the coordinates and size of an object, either as a
typed dictionary or as a tuple.
Args:
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
to None, which utilizes the object's description value.
Returns:
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
if `typed` is True.
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
"""
if typed is None:
typed = self.description
d = self.state["size"][0]
cx = self.pos().x() + d / 2
cy = self.pos().y() + d / 2
if typed:
return {"center_x": cx, "center_y": cy, "diameter": d, "radius": d / 2}
return (cx, cy, d, d / 2)
def _lookup_scene_image(self) -> pg.ImageItem | None:
"""
Retrieves an image from the scene items if available.
Iterates over all items in the scene and checks if any of them are of type
`pg.ImageItem` and have a non-None image. If such an item is found, its image
is returned.
Returns:
pg.ImageItem or None: The first found ImageItem with a non-None image,
or None if no suitable image is found.
"""
for it in self.scene().items():
if isinstance(it, pg.ImageItem) and it.image is not None:
return it.image
return None
class ROIController(QObject):
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
Handles creating, adding, removing, and managing ROI instances. Supports color assignment
from a colormap, and provides utility methods to access and manipulate ROIs.
Attributes:
roiAdded (Signal): Emits the new ROI instance when added.
roiRemoved (Signal): Emits the removed ROI instance when deleted.
cleared (Signal): Emits when all ROIs are removed.
paletteChanged (Signal): Emits the new colormap name when updated.
_colormap (str): Name of the colormap used for ROI colors.
_rois (list[BaseROI]): Internal list storing currently managed ROIs.
_colors (list[str]): Internal list of colors for the ROIs.
"""
roiAdded = Signal(object) # emits the new ROI instance
roiRemoved = Signal(object) # emits the removed ROI instance
cleared = Signal() # emits when all ROIs are removed
paletteChanged = Signal(str) # emits new colormap name
def __init__(self, colormap="viridis"):
"""
Initializes the ROI controller with the specified colormap.
Sets up internal data structures for managing ROIs and their colors.
Args:
colormap (str, optional): The name of the colormap to use for ROI colors.
Defaults to "viridis".
"""
super().__init__()
self._colormap = colormap
self._rois: list[BaseROI] = []
self._colors: list[str] = []
self._rebuild_color_buffer()
def _rebuild_color_buffer(self):
"""
Regenerates the color buffer for ROIs.
This internal method creates a new list of colors based on the current colormap
and the number of ROIs. It ensures there's always one more color than the number
of ROIs to allow for adding a new ROI without regenerating the colors.
"""
n = len(self._rois) + 1
self._colors = Colors.golden_angle_color(colormap=self._colormap, num=n, format="HEX")
def add_roi(self, roi: BaseROI):
"""
Registers an externally created ROI with this controller.
Adds the ROI to the internal list, assigns it a color from the color buffer,
ensures it has an appropriate line width, and emits the roiAdded signal.
Args:
roi (BaseROI): The ROI instance to register. Can be any subclass of BaseROI,
such as RectangularROI or CircularROI.
"""
self._rois.append(roi)
self._rebuild_color_buffer()
idx = len(self._rois) - 1
if roi.label == "ROI" or roi.label.startswith("ROI "):
roi.label = f"ROI {idx}"
color = self._colors[idx]
roi.line_color = color
# ensure line width default is at least 3 if not previously set
if getattr(roi, "line_width", 0) < 1:
roi.line_width = 10
self.roiAdded.emit(roi)
def remove_roi(self, roi: BaseROI):
"""
Removes an ROI from this controller.
If the ROI is found in the internal list, it is removed, the color buffer
is regenerated, and the roiRemoved signal is emitted.
Args:
roi (BaseROI): The ROI instance to remove.
"""
rois = self._rois
if roi not in rois:
roi.remove()
def get_roi(self, index: int) -> BaseROI | None:
"""
Returns the ROI at the specified index.
Args:
index (int): The index of the ROI to retrieve.
Returns:
BaseROI or None: The ROI at the specified index, or None if the index
is out of range.
"""
if 0 <= index < len(self._rois):
return self._rois[index]
return None
def get_roi_by_name(self, name: str) -> BaseROI | None:
"""
Returns the first ROI with the specified name.
Args:
name (str): The name to search for (case-sensitive).
Returns:
BaseROI or None: The first ROI with a matching name, or None if no
matching ROI is found.
"""
for r in self._rois:
if r.label == name:
return r
return None
def remove_roi_by_index(self, index: int):
"""
Removes the ROI at the specified index.
Args:
index (int): The index of the ROI to remove.
"""
roi = self.get_roi(index)
if roi is not None:
roi.remove()
def remove_roi_by_name(self, name: str):
"""
Removes the first ROI with the specified name.
Args:
name (str): The name of the ROI to remove (case-sensitive).
"""
roi = self.get_roi_by_name(name)
if roi is not None:
roi.remove()
def clear(self):
"""
Removes all ROIs from this controller.
Iterates through all ROIs and removes them one by one, then emits
the cleared signal to notify listeners that all ROIs have been removed.
"""
for roi in list(self._rois):
roi.remove()
self.cleared.emit()
def renormalize_colors(self):
"""
Reassigns palette colors to all ROIs in order.
Regenerates the color buffer based on the current colormap and number of ROIs,
then assigns each ROI a color from the buffer in the order they were added.
This is useful after changing the colormap or when ROIs need to be visually
distinguished from each other.
"""
self._rebuild_color_buffer()
for idx, roi in enumerate(self._rois):
roi.line_color = self._colors[idx]
@SafeProperty(str)
def colormap(self):
"""
Gets the name of the colormap used for ROI colors.
Returns:
str: The name of the colormap.
"""
return self._colormap
@colormap.setter
def colormap(self, cmap: str):
"""
Sets the colormap used for ROI colors.
Updates the internal colormap name and reassigns colors to all ROIs
based on the new colormap.
Args:
cmap (str): The name of the colormap to use (e.g., "viridis", "plasma").
"""
self.set_colormap(cmap)
def set_colormap(self, cmap: str):
Colors.validate_color_map(cmap)
self._colormap = cmap
self.paletteChanged.emit(cmap)
self.renormalize_colors()
@property
def rois(self) -> list[BaseROI]:
"""
Gets a copy of the list of ROIs managed by this controller.
Returns a new list containing all the ROIs currently managed by this controller.
The list is a copy, so modifying it won't affect the controller's internal list.
Returns:
list[BaseROI]: A list of all ROIs currently managed by this controller.
"""
return list(self._rois)
def cleanup(self):
for roi in self._rois:
self.remove_roi(roi)

View File

@@ -31,6 +31,9 @@ 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
@@ -40,49 +43,6 @@ 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.
@@ -193,7 +153,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 = ColorButton(self.config.color)
self.color_button = ColorButtonNative(color=self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.tree.setItemWidget(self, 3, self.color_button)
@@ -284,6 +244,11 @@ 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:
@@ -337,8 +302,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()

View File

@@ -1182,10 +1182,11 @@ 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)
@SafeSlot(dict, dict, verify_sender=True)
def on_async_readback(self, msg, metadata):
"""
Get async data readback. This code needs to be fast, therefor we try
@@ -1204,6 +1205,14 @@ 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}")
@@ -1212,6 +1221,7 @@ 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:

View File

@@ -21,6 +21,7 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum",
"label_template",
"label_template.setter",
"_get_label",
]
ICON_NAME = "page_control"
@@ -235,6 +236,10 @@ 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

@@ -76,6 +76,7 @@ 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)
@@ -134,6 +135,10 @@ 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

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

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

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

View File

@@ -0,0 +1,56 @@
# 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()

View File

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

View File

@@ -1,4 +1,5 @@
from pyqtgraph.widgets.ColorMapButton import ColorMapButton
from qtpy import QtCore, QtGui
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
@@ -6,6 +7,23 @@ from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget
class RoundedColorMapButton(ColorMapButton):
"""Thin wrapper around pyqtgraph ColorMapButton to add rounded clipping."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
def paintEvent(self, evt):
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
path = QtGui.QPainterPath()
path.addRoundedRect(self.rect(), 8, 8)
painter.setClipPath(path)
self.paintColorMap(painter, self.contentsRect())
painter.end()
class BECColorMapWidget(BECWidget, QWidget):
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
@@ -15,7 +33,7 @@ class BECColorMapWidget(BECWidget, QWidget):
def __init__(self, parent=None, cmap: str = "plasma", **kwargs):
super().__init__(parent=parent, **kwargs)
# Create the ColorMapButton
self.button = ColorMapButton()
self.button = RoundedColorMapButton()
# Set the size policy and minimum width
size_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)

View File

@@ -19,13 +19,11 @@ from bec_widgets.utils.bec_widget import BECWidget
class HelloWorldWidget(BECWidget, QWidget):
def __init__(
self, parent: QWidget | None = None, client=None, gui_id: str | None = None
self, parent: QWidget | None = None, client=None, gui_id: str | None = None, **kwargs
) -> None:
# Initialize the BECWidget and QWidget
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent)
# Initialize base classes
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
# Create a label with the text "Hello World"
self.label = QLabel(self)
self.label.setText("Hello World")

View File

@@ -7,6 +7,4 @@ sphinx-copybutton
sphinx-inline-tabs
myst-parser
sphinx-design
PySide6~=6.8.2
bec-widgets
tomli

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.0.3"
version = "2.5.2"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -16,7 +16,7 @@ dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=3.29, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=24.0", # needed for bw-generate-cli
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
@@ -31,12 +31,14 @@ dependencies = [
dev = [
"coverage~=7.0",
"fakeredis~=2.23, >=2.23.2",
"isort~=5.13, >=5.13.2",
"pytest-bec-e2e>=2.21.4, <=4.0",
"pytest-qt~=4.4",
"pytest-random-order~=1.1",
"pytest-timeout~=2.2",
"pytest-xvfb~=3.0",
"pytest~=8.0",
"pytest-cov~=6.1.1",
]
[project.urls]
@@ -69,7 +71,7 @@ include_trailing_comma = true
known_first_party = ["bec_widgets"]
[tool.semantic_release]
build_command = "python -m build"
build_command = "pip install build wheel && python -m build"
version_toml = ["pyproject.toml:project.version"]
[tool.semantic_release.commit_author]
@@ -95,12 +97,23 @@ default_bump_level = 0
[tool.semantic_release.remote]
name = "origin"
type = "gitlab"
ignore_token_for_push = false
type = "github"
ignore_token_for_push = true
[tool.semantic_release.remote.token]
env = "GL_TOKEN"
env = "GH_TOKEN"
[tool.semantic_release.publish]
dist_glob_patterns = ["dist/*"]
upload_to_vcs_release = true
[tool.coverage.report]
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"return NotImplemented",
"raise NotImplementedError",
"\\.\\.\\.",
'if __name__ == "__main__":',
]

View File

@@ -4,8 +4,7 @@ import random
import pytest
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
from bec_widgets.utils import BECDispatcher
from bec_widgets.cli.client_utils import BECGuiClient
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@@ -28,7 +27,7 @@ def gui_id():
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
@pytest.fixture
@pytest.fixture(scope="function")
def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
"""
Fixture to create a new BECGuiClient object and start a server in the background.
@@ -42,22 +41,3 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
yield gui
finally:
gui.kill_server()
@pytest.fixture(scope="session")
def connected_gui_with_scope_session(qtbot, gui_id, bec_client_lib):
"""
Fixture to create a new BECGuiClient object and start a server in the background.
This fixture is scoped to the session, meaning it remains alive for all tests in the session.
We can use this fixture to create a gui object that is used across multiple tests, and
simulate a real-world scenario where the gui is not restarted for each test.
"""
gui = BECGuiClient(gui_id=gui_id)
try:
gui.start(wait=True)
# After the server started, we need to wait until the bec exists in the namespace
qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
yield gui
finally:
gui.kill_server()

View File

@@ -1,5 +1,5 @@
"""
Test module for the gui object within the BEC IPython client.
Test module for the gui object within the BEC IPython client.
"""
from unittest import mock

View File

@@ -136,7 +136,7 @@ def test_async_plotting(qtbot, bec_client_lib, connected_client_gui_obj):
dev.waveform.sim.select_model("GaussianModel")
dev.waveform.sim.params = {"amplitude": 1000, "center": 4000, "sigma": 300}
dev.waveform.async_update.set("add").wait()
dev.waveform.waveform_shape.set(1000).wait()
dev.waveform.waveform_shape.set(10000).wait()
wf = dock.new("wf_dock").new("Waveform")
curve = wf.plot(y_name="waveform")

View File

@@ -9,7 +9,7 @@ from bec_widgets.cli.rpc.rpc_base import RPCReference
def test_rpc_reference_objects(connected_client_gui_obj):
gui = connected_client_gui_obj
dock = gui.window_list[0].new("dock")
dock = gui.window_list[0].new()
plt = dock.new(name="fig", widget="Waveform")
plt.plot(x_name="samx", y_name="bpm4i")

View File

@@ -96,6 +96,10 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
if object_name == "VSCodeEditor":
continue
# Skip WebConsole as ttyd is not installed
if object_name == "WebConsole":
continue
#############################
######### Add widget ########
#############################

View File

@@ -0,0 +1,82 @@
"""
End-2-End test fixtures for module scoped testing. The fixtures overwrite the default versions used
for the function scoped tests. The fixtures will only be created once for this entire module, meaning
that any test can be used to test user interaction and potential leakage of threads or other resources across
different widgets.
"""
import random
import pytest
from bec_ipython_client import BECIPythonClient
from bec_lib.redis_connector import RedisConnector
from bec_lib.service_config import ServiceConfig
from bec_lib.tests.utils import wait_for_empty_queue
from pytestqt.plugin import QtBot
from bec_widgets.cli.client_utils import BECGuiClient
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@pytest.fixture(scope="module")
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}"
@pytest.fixture(scope="module")
def bec_ipython_client_with_demo_config(
bec_redis_fixture, bec_services_config_file_path, bec_servers
):
"""Fixture to create a BECIPythonClient with a demo config."""
config = ServiceConfig(bec_services_config_file_path)
bec = BECIPythonClient(config, RedisConnector, forced=True)
bec.start()
bec.config.load_demo_config()
try:
yield bec
finally:
bec.shutdown()
bec._client._reset_singleton()
@pytest.fixture(scope="module")
def bec_client_lib(bec_ipython_client_with_demo_config):
"""Fixture to create a BECIPythonClient with a demo config."""
bec = bec_ipython_client_with_demo_config
bec.queue.request_queue_reset()
bec.queue.request_scan_continuation()
wait_for_empty_queue(bec)
yield bec
@pytest.fixture(scope="module")
def qtbot_scope_module(qapp, request):
"""
Fixture used to create a QtBot instance for using during testing.
Make sure to call addWidget for each top-level widget you create to ensure
that they are properly closed after the test ends.
"""
result = QtBot(request)
return result
@pytest.fixture(scope="module")
def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib):
"""
Fixture to create a new BECGuiClient object and start a server in the background.
This fixture is scoped to the session, meaning it remains alive for all tests in the session.
We can use this fixture to create a gui object that is used across multiple tests, and
simulate a real-world scenario where the gui is not restarted for each test.
"""
gui = BECGuiClient(gui_id=gui_id)
try:
gui.start(wait=True)
qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
yield gui
finally:
gui.kill_server()

View File

@@ -0,0 +1,667 @@
"""
End-to-end tests single gui instance across the full session.
Each test will use the same gui instance, simulating a real-world scenario where the gui is not
restarted for each test. The interaction is tested through the rpc calls.
Note: wait_for_namespace_created is a utility method that helps to wait for the namespace to be
created in the gui. This is necessary because the rpc calls are asynchronous and the namespace
may not be created immediately after the rpc call is made.
"""
from __future__ import annotations
import random
from typing import TYPE_CHECKING, Any
import numpy as np
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
PYTEST_TIMEOUT = 50
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.cli import client
from bec_widgets.cli.client_utils import BECGuiClient
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-arguments
# pylint: disable=protected-access
# pylint: disable=unused-variable
def wait_for_namespace_change(
qtbot,
gui: BECGuiClient,
parent_widget: RPCBase | RPCReference,
object_name: str,
widget_gui_id: str,
timeout: float = 10000,
exists: bool = True,
):
"""
Utility method to wait for the namespace to be created in the widget.
Args:
qtbot: The qtbot fixture.
gui: The client_utils.BECGuiClient 'gui' object from the CLI.
parent_widget: The widget that creates a new widget.
object_name: The name of the widget that was created. Must appear as attribute in namespace of parent.
widget_gui_id: The gui_id of the created widget.
timeout: The timeout in milliseconds for the qtbot to wait for changes to appear.
exists: If True, wait for the object to be created. If False, wait for the object to be removed.
"""
# GUI object is not registered in the registry (yet)
if parent_widget is gui:
def check_reference_registered():
# Check server registry
obj = gui._server_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
# CHeck Ipython registry
obj = gui._ipython_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
else:
def check_reference_registered():
# Check server registry
obj = gui._server_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
# CHeck Ipython registry
obj = gui._ipython_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
# Check reference registry
ref = parent_widget._rpc_references.get(widget_gui_id, None)
if exists:
return ref is not None
return ref is None
try:
qtbot.waitUntil(check_reference_registered, timeout=timeout)
except Exception as e:
raise RuntimeError(
f"Timeout waiting for {parent_widget.object_name}.{object_name} to be created."
) from e
def create_widget(
qtbot, gui: BECGuiClient, widget_cls_name: str
) -> tuple[RPCReference, RPCReference]:
"""Utility method to create a widget and wait for the namespaces to be created."""
if hasattr(gui, "dock_area"):
dock_area: client.BECDockArea = gui.dock_area
else:
dock_area: client.BECDockArea = gui.new(name="dock_area")
wait_for_namespace_change(qtbot, gui, gui, dock_area.object_name, dock_area._gui_id)
dock: client.BECDock = dock_area.new()
wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id)
widget = dock.new(widget=widget_cls_name)
wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id)
return dock, widget
@pytest.fixture(scope="module")
def random_generator_from_seed(request):
"""Fixture to get a random seed for the following tests."""
seed = request.config.getoption("--random-order-seed").split(":")[-1]
try:
seed = int(seed)
except ValueError: # Should not be required...
seed = 42
rng = random.Random(seed)
yield rng
def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Random):
"""Utility method to remove all dock_ares from gui object, likelihood 50%."""
random_int = random_int_gen.randint(0, 100)
if random_int >= 50:
# Needed, reference gets deleted in the gui
name = gui.dock_area.object_name
gui_id = gui.dock_area._gui_id
gui.delete("dock_area")
wait_for_namespace_change(
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the AbortButton widget."""
gui: BECGuiClient = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton)
dock: client.BECDock
widget: client.AbortButton
# No rpc calls to check so far
# Try detaching the dock
dock.detach()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the BECProgressBar widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
dock: client.BECDock
widget: client.BECProgressBar
# Check rpc calls
assert widget.label_template == "$value / $maximum - $percentage %"
widget.set_maximum(100)
widget.set_minimum(50)
widget.set_value(75)
assert widget._get_label() == "75 / 100 - 50 %"
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the BECQueue widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
dock: client.BECDock
widget: client.BECQueue
# No rpc calls to test so far
# maybe we can add an rpc call to check the queue length
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the BECStatusBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
# Check rpc calls
assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DAPComboBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
dock: client.BECDock
widget: client.DAPComboBox
# Check rpc calls
widget.select_fit_model("PseudoVoigtModel")
widget.select_x_axis("samx")
widget.select_y_axis("bpm4i")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceBrowser widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
dock: client.BECDock
widget: client.DeviceBrowser
# No rpc calls yet to check
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceComboBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox)
dock: client.BECDock
widget: client.DeviceComboBox
# No rpc calls to check so far, maybe set_device should be exposed
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceLineEdit widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit)
dock: client.BECDock
widget: client.DeviceLineEdit
# No rpc calls to check so far
# Should probably have a set_device method
# No rpc calls to check so far, maybe set_device should be exposed
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the Image widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Image)
dock: client.BECDock
widget: client.Image
scans = bec.scans
dev = bec.device_manager.devices
# Test rpc calls
img = widget.image(dev.eiger)
assert img.get_data() is None
# Run a scan and plot the image
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait()
def _wait_for_scan_in_history():
# Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# Check that last image is equivalent to data in Redis
last_img = bec.device_monitor.get_data(
dev.eiger, count=1
) # Get last image from Redis monitor 2D endpoint
assert np.allclose(img.get_data(), last_img)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# TODO re-enable when issue is resolved #560
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the LogPanel widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area, dock, widget
# dock, widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# dock: client.BECDock
# widget: client.LogPanel
# # No rpc calls to check so far
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
dock: client.BECDock
widget: client.MineSweeper
# No rpc calls to check so far
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MotorMap widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
dock: client.BECDock
widget: client.MotorMap
# Test RPC calls
dev = bec.device_manager.devices
scans = bec.scans
# Set motor map to names
widget.map(dev.samx, dev.samy)
# Move motor samx to pos
pos = dev.samx.limits[1] - 1 # -1 from higher limit
scans.mv(dev.samx, pos, relative=False).wait()
# Check that data is up to date
assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
# Move motor samy to pos
pos = dev.samy.limits[0] + 1 # +1 from lower limit
scans.mv(dev.samy, pos, relative=False).wait()
# Check that data is up to date
assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test MultiWaveform widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
dock: client.BECDock
widget: client.MultiWaveform
# Test RPC calls
dev = bec.device_manager.devices
scans = bec.scans
# test plotting
cm = "cividis"
widget.plot(dev.waveform, color_palette=cm)
assert widget.monitor == dev.waveform.name
assert widget.color_palette == cm
# Scan with BEC
s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
s.wait()
def _wait_for_scan_in_history():
# Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# Wait for data in history (should be plotted?)
# TODO how can we check that the data was plotted, implement get_data()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_indicator(
qtbot, connected_client_gui_obj, random_generator_from_seed
):
"""Test the PositionIndicator widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
dock: client.BECDock
widget: client.PositionIndicator
# TODO check what these rpc calls are supposed to do! Issue created #461
widget.set_value(5)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the PositionerBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
dock: client.BECDock
widget: client.PositionerBox
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# No rpc calls to check so far
widget.set_positioner(dev.samx)
widget.set_positioner(dev.samy.name)
scans.mv(dev.samy, -3, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the PositionerBox2D widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
dock: client.BECDock
widget: client.PositionerBox2D
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# No rpc calls to check so far
widget.set_positioner_hor(dev.samx)
widget.set_positioner_ver(dev.samy)
# Try moving the motors
scans.mv(dev.samx, 3, relative=False).wait()
scans.mv(dev.samy, -3, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_control_line(
qtbot, connected_client_gui_obj, random_generator_from_seed
):
"""Test the positioner control line widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
dock: client.BECDock
widget: client.PositionerControlLine
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# Set positioner
widget.set_positioner(dev.samx)
scans.mv(dev.samx, 3, relative=False).wait()
widget.set_positioner(dev.samy.name)
scans.mv(dev.samy, -3, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the RingProgressBar widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar)
dock: client.BECDock
widget: client.RingProgressBar
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# Do a scan
scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the ScanControl widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
dock: client.BECDock
widget: client.ScanControl
# No rpc calls to check so far
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the ScatterWaveform widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
dock: client.BECDock
widget: client.ScatterWaveform
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
widget.plot(dev.samx, dev.samy, dev.bpm4i)
scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the StopButton widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton)
dock: client.BECDock
widget: client.StopButton
# No rpc calls to check so far
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the StopButton widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton)
dock: client.BECDock
widget: client.ResumeButton
# No rpc calls to check so far
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the StopButton widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton)
dock: client.BECDock
widget: client.ResetButton
# No rpc calls to check so far
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the TextBox widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
dock: client.BECDock
widget: client.TextBox
# RPC calls
widget.set_plain_text("Hello World")
widget.set_html_text("<b> Hello World HTML </b>")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the Waveform widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
dock: client.BECDock
widget: client.Waveform
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
widget.plot(dev.bpm4i)
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait()
def _wait_for_scan_in_history():
# Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
samx_data = scan_item.devices.samx.samx.read()["value"]
bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
curve = widget.curves[0]
assert np.allclose(curve.get_data()[0], samx_data)
assert np.allclose(curve.get_data()[1], bpm4i_data)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)

View File

@@ -82,3 +82,52 @@ def test_bec_connector_submit_task(bec_connector):
while not completed:
QApplication.processEvents()
time.sleep(0.1)
def test_bec_connector_change_object_name(bec_connector):
# Store the original object name and RPC register state
original_name = bec_connector.objectName()
original_gui_id = bec_connector.gui_id
# Call the method with a new name
new_name = "new_test_name"
bec_connector.change_object_name(new_name)
# Process events to allow the single shot timer to execute
QApplication.processEvents()
# Verify that the object name was changed correctly
assert bec_connector.objectName() == new_name
assert bec_connector.object_name == new_name
# Verify that the object is registered in the RPC register with the new name
assert bec_connector.rpc_register.object_is_registered(bec_connector)
# Verify that the object with the original name is no longer registered
# The object should still have the same gui_id
assert bec_connector.gui_id == original_gui_id
# Check that no object with the original name exists in the RPC register
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == original_name for obj in all_objects)
# Store the current name for the next test
previous_name = bec_connector.objectName()
# Test with spaces and hyphens
name_with_spaces_and_hyphens = "test name-with-hyphens"
expected_name = "test_name_with_hyphens"
bec_connector.change_object_name(name_with_spaces_and_hyphens)
# Process events to allow the single shot timer to execute
QApplication.processEvents()
# Verify that the object name was changed correctly with replacements
assert bec_connector.objectName() == expected_name
assert bec_connector.object_name == expected_name
# Verify that the object is still registered in the RPC register after the second name change
assert bec_connector.rpc_register.object_is_registered(bec_connector)
# Verify that the object with the previous name is no longer registered
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == previous_name for obj in all_objects)

View File

@@ -7,7 +7,7 @@ import pytest
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
@pytest.fixture
@@ -27,6 +27,7 @@ def bec_dispatcher_w_connector(bec_dispatcher, topics_msg_list, send_msg_event):
connector = QtRedisConnector("localhost:1", redis_class_mock)
bec_dispatcher.client.connector = connector
yield bec_dispatcher
connector.shutdown()
dummy_msg = MsgpackSerialization.dumps(ScanMessage(point_id=0, scan_id="0", data={}))
@@ -62,7 +63,6 @@ def test_dispatcher_disconnect_all(bec_dispatcher_w_connector, qtbot, send_msg_e
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_one(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
@@ -86,12 +86,21 @@ def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
num_slots = len(bec_dispatcher._registered_slots)
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb2, "topic1")
# The redis connector should only subscribe once to the topic
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert len(bec_dispatcher._slots) == 2
# The the given topic, two callbacks should be registered
assert len(bec_dispatcher.client.connector._topics_cb["topic1"]) == 2
# The dispatcher should have two slots
assert len(bec_dispatcher._registered_slots) == num_slots + 2
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher._slots) == 1
assert len(bec_dispatcher._registered_slots) == num_slots + 1
send_msg_event.set()
qtbot.wait(10)
@@ -99,9 +108,31 @@ def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_
cb2.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg),)])
def test_dispatcher_2_cb_same_topic_same_slot(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 1
)
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 1
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 0
)
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
@@ -114,3 +145,36 @@ def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_
send_msg_event.set()
qtbot.wait(10)
cb1.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb_with_boundmethod(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
bec_dispatcher = bec_dispatcher_w_connector
class MockObject:
def mock_slot(self, msg, metadata):
pass
cb1 = MockObject()
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
def _get_slots():
return list(
filter(
lambda slot: slot == QtThreadSafeCallback(cb1.mock_slot, {"metadata": "test"}),
bec_dispatcher._registered_slots.values(),
)
)
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert len(_get_slots()) == 1
bec_dispatcher.disconnect_slot(cb1.mock_slot, "topic1")
assert len(bec_dispatcher.client.connector._topics_cb) == 0
assert len(_get_slots()) == 0
send_msg_event.set()
qtbot.wait(10)

View File

@@ -89,6 +89,13 @@ def test_undock_and_dock_docks(bec_dock_area, qtbot):
assert len(bec_dock_area.dock_area.tempAreas) == 0
def test_new_dock_raises_for_invalid_name(bec_dock_area):
with pytest.raises(ValueError):
bec_dock_area.new(
name="new", _override_slot_params={"popup_error": False, "raise_error": True}
)
###################################
# Toolbar Actions
###################################

View File

@@ -7,6 +7,7 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
DeviceInputConfig,
)
from .client_mocks import mocked_client
@@ -51,9 +52,13 @@ def test_device_input_base_init_with_config(mocked_client):
"default": "samx",
}
widget = DeviceInputWidget(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.device_filter == ["Positioner"]
assert widget.config.default == "samx"
widget2 = DeviceInputWidget(
client=mocked_client, config=DeviceInputConfig.model_validate(config)
)
for w in [widget, widget2]:
assert w.config.gui_id == "test_gui_id"
assert w.config.device_filter == ["Positioner"]
assert w.config.default == "samx"
def test_device_input_base_set_device_filter(device_input_base):

View File

@@ -74,6 +74,7 @@ def test_client_generator_with_black_formatting():
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
from bec_lib.logger import bec_logger

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
from typing import Literal
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture(params=["rect", "circle"])
def bec_image_widget_with_roi(qtbot, request, mocked_client):
"""Return (widget, roi, shape_label) for each ROI class."""
roi_type: Literal["rect", "circle"] = request.param
# Build an Image widget with a trivial 100×100 zeros array
widget: Image = create_widget(qtbot, Image, client=mocked_client)
data = np.zeros((100, 100), dtype=float)
data[20:40, 20:40] = 5 # content assertion for roi to check
widget.main_image.set_data(data)
# Add a single ROI via the public API
roi = widget.add_roi(kind=roi_type)
yield widget, roi, roi_type
def test_default_properties(bec_image_widget_with_roi):
"""Label, width, type sanitycheck."""
_widget, roi, roi_type = bec_image_widget_with_roi
assert roi.label.startswith("ROI")
assert roi.line_width == 10
# concrete subclass type
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
def test_coordinate_structures(bec_image_widget_with_roi):
"""Typed vs untyped coordinate structures are consistent."""
_widget, roi, _ = bec_image_widget_with_roi
raw = roi.get_coordinates(typed=False)
typed = roi.get_coordinates(typed=True)
# untyped is always a tuple
assert isinstance(raw, tuple)
# typed is always a dict and has same number of scalars as raw flattens to
assert isinstance(typed, dict)
assert sum(isinstance(v, (tuple, list)) and len(v) or 1 for v in typed.values()) == len(
np.ravel(raw)
)
def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
"""Pixels reported by get_data_from_image have nonzero size and match ROI extents."""
widget, roi, _ = bec_image_widget_with_roi
pixels = roi.get_data_from_image() # autodetect ImageItem
assert pixels.size > 0 # ROI covers at least one pixel
# For rectangular ROI: pixel bounding box equals coordinate bbox
if isinstance(roi, RectangularROI):
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
# ensure ints inside image shape
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
expected = widget.main_image.image[y0:y1, x0:x1]
assert pixels.shape == expected.shape
@pytest.mark.parametrize("index", [0])
def test_controller_remove_by_index(bec_image_widget_with_roi, index):
"""Image.remove_roi(index) removes the graphics item and updates controller."""
widget, roi, _ = bec_image_widget_with_roi
controller: ROIController = widget.roi_controller
assert controller.rois # nonempty before
widget.remove_roi(index)
# ROI list now empty and item no longer in scene
assert not controller.rois
assert roi not in widget.plot_item.items
def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
widget: Image = create_widget(qtbot, Image, client=mocked_client)
# add two of each ROI type
for _kind in ("rect", "circle"):
widget.add_roi(kind=_kind)
widget.add_roi(kind=_kind)
colors = [r.line_color for r in widget.roi_controller.rois]
assert len(colors) == len(set(colors)), "Colors must be unique per ROI"
def test_roi_label_and_signals(bec_image_widget_with_roi):
widget, roi, _ = bec_image_widget_with_roi
changed = []
roi.nameChanged.connect(lambda name: changed.append(name))
roi.label = "new_label"
assert roi.label == "new_label"
assert changed and changed[0] == "new_label"
def test_roi_line_color_and_width(bec_image_widget_with_roi):
_widget, roi, _ = bec_image_widget_with_roi
changed = []
roi.penChanged.connect(lambda: changed.append(True))
roi.line_color = "#123456"
assert roi.line_color == "#123456"
roi.line_width = 5
assert roi.line_width == 5
assert changed # penChanged should have been emitted
def test_roi_controller_add_remove_multiple(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
controller = widget.roi_controller
r1 = widget.add_roi(kind="rect", name="r1")
r2 = widget.add_roi(kind="circle", name="c1")
assert r1 in controller.rois and r2 in controller.rois
widget.remove_roi("r1")
assert r1 not in controller.rois and r2 in controller.rois
widget.remove_roi("c1")
assert not controller.rois
def test_roi_controller_colormap_changes(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
controller = widget.roi_controller
widget.add_roi(kind="rect")
widget.add_roi(kind="circle")
old_colors = [r.line_color for r in controller.rois]
controller.colormap = "plasma"
new_colors = [r.line_color for r in controller.rois]
assert old_colors != new_colors
assert all(isinstance(c, str) for c in new_colors)
def test_roi_controller_clear(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
widget.add_roi(kind="rect")
widget.add_roi(kind="circle")
controller = widget.roi_controller
controller.clear()
assert not controller.rois
def test_roi_get_data_from_image_no_image(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
roi = widget.add_roi(kind="rect")
# Remove all images from scene
for item in list(widget.plot_item.items):
if hasattr(item, "image"):
widget.plot_item.removeItem(item)
import pytest
with pytest.raises(RuntimeError):
roi.get_data_from_image()
def test_roi_remove_cleans_up(bec_image_widget_with_roi):
widget, roi, _ = bec_image_widget_with_roi
roi.remove()
assert roi not in widget.roi_controller.rois
assert roi not in widget.plot_item.items
def test_roi_controller_get_roi_methods(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
r1 = widget.add_roi(kind="rect", name="findme")
r2 = widget.add_roi(kind="circle")
controller = widget.roi_controller
assert controller.get_roi_by_name("findme") == r1
assert controller.get_roi(1) == r2
assert controller.get_roi(99) is None
assert controller.get_roi_by_name("notfound") is None

View File

@@ -329,3 +329,60 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
assert bec_image_view.main_image.log is False
assert bec_image_view.transpose is False
assert bec_image_view.main_image.transpose is False
def test_roi_add_remove_and_properties(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
# Add ROIs
rect = view.add_roi(kind="rect", name="rect_roi", line_width=7)
circ = view.add_roi(kind="circle", name="circ_roi", line_width=5)
assert rect in view.roi_controller.rois
assert circ in view.roi_controller.rois
assert rect.label == "rect_roi"
assert circ.label == "circ_roi"
assert rect.line_width == 7
assert circ.line_width == 5
# Change properties
rect.label = "rect_roi2"
circ.line_color = "#ff0000"
assert rect.label == "rect_roi2"
assert circ.line_color == "#ff0000"
# Remove by name
view.remove_roi("rect_roi2")
assert rect not in view.roi_controller.rois
# Remove by index
view.remove_roi(0)
assert not view.roi_controller.rois
def test_roi_controller_palette_signal(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
controller = view.roi_controller
changed = []
controller.paletteChanged.connect(lambda cmap: changed.append(cmap))
view.add_roi(kind="rect")
controller.colormap = "plasma"
assert changed and changed[0] == "plasma"
def test_roi_controller_clear_and_get_methods(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
r1 = view.add_roi(kind="rect", name="r1")
r2 = view.add_roi(kind="circle", name="c1")
controller = view.roi_controller
assert controller.get_roi_by_name("r1") == r1
assert controller.get_roi(1) == r2
controller.clear()
assert not controller.rois
def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
roi = view.add_roi(kind="rect")
# Remove all images from scene
for item in list(view.plot_item.items):
if hasattr(item, "image"):
view.plot_item.removeItem(item)
with pytest.raises(RuntimeError):
roi.get_data_from_image()

View File

@@ -64,7 +64,7 @@ def test_launch_window_launch_ui_file_raises_for_qmainwindow(bec_launch_window):
def test_launch_window_launch_default_auto_update(bec_launch_window):
# Mock the auto update selection
bec_launch_window.tile_auto_update.selector.setCurrentText("Default")
bec_launch_window.tiles["auto_update"].selector.setCurrentText("Default")
# Call the method to launch the auto update
res = bec_launch_window._open_auto_update()
@@ -82,11 +82,11 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
class PluginAutoUpdate(AutoUpdates): ...
bec_launch_window.available_auto_updates = {"PluginAutoUpdate": PluginAutoUpdate}
bec_launch_window.tile_auto_update.selector.clear()
bec_launch_window.tile_auto_update.selector.addItems(
bec_launch_window.tiles["auto_update"].selector.clear()
bec_launch_window.tiles["auto_update"].selector.addItems(
list(bec_launch_window.available_auto_updates.keys()) + ["Default"]
)
bec_launch_window.tile_auto_update.selector.setCurrentText("PluginAutoUpdate")
bec_launch_window.tiles["auto_update"].selector.setCurrentText("PluginAutoUpdate")
res = bec_launch_window._open_auto_update()
assert isinstance(res, PluginAutoUpdate)
assert res.windowTitle() == "BEC - PluginAutoUpdate"

View File

@@ -1,4 +1,5 @@
from typing import Optional
from __future__ import annotations
from unittest.mock import patch
import pytest
@@ -8,7 +9,8 @@ from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutM
class MockWidgetHandler:
def create_widget(self, widget_type: str) -> Optional[QWidget]:
def create_widget(self, widget_type: str) -> QWidget | None:
if widget_type == "ButtonWidget":
return QPushButton()
elif widget_type == "LabelWidget":
@@ -225,13 +227,11 @@ def test_add_widget_overlap_with_span(layout_manager):
@pytest.mark.parametrize(
"position, btn3_coords",
[("left", (1, 0)), ("right", (1, 2)), ("top", (0, 1)), ("bottom", (2, 1))],
"position, expected_position",
[("left", "left"), ("right", "right"), ("top", "top"), ("bottom", "bottom")],
)
def test_add_widget_relative(layout_manager, position, btn3_coords):
def test_add_widget_relative(layout_manager, position, expected_position):
"""Test adding a widget relative to an existing widget using parameterized data."""
expected_row, expected_col = btn3_coords
btn1 = QPushButton("Button 1")
btn2 = QPushButton("Button 2")
btn3 = QPushButton("Button 3")
@@ -241,9 +241,28 @@ def test_add_widget_relative(layout_manager, position, btn3_coords):
layout_manager.add_widget_relative(btn3, reference_widget=btn2, position=position)
assert layout_manager.get_widget(0, 0) == btn1
assert layout_manager.get_widget(1, 1) == btn2
assert layout_manager.get_widget(expected_row, expected_col) == btn3
# Get the actual positions of the widgets
btn1_pos = layout_manager.widget_positions[btn1]
btn2_pos = layout_manager.widget_positions[btn2]
btn3_pos = layout_manager.widget_positions[btn3]
# Check that btn1 and btn2 are still in the layout
assert btn1 in layout_manager.widget_positions
assert btn2 in layout_manager.widget_positions
# Check that btn3 is positioned correctly relative to btn2
if expected_position == "left":
assert btn3_pos[1] < btn2_pos[1] # btn3's column < btn2's column
assert btn3_pos[0] == btn2_pos[0] # same row
elif expected_position == "right":
assert btn3_pos[1] > btn2_pos[1] # btn3's column > btn2's column
assert btn3_pos[0] == btn2_pos[0] # same row
elif expected_position == "top":
assert btn3_pos[0] < btn2_pos[0] # btn3's row < btn2's row
assert btn3_pos[1] == btn2_pos[1] # same column
elif expected_position == "bottom":
assert btn3_pos[0] > btn2_pos[0] # btn3's row > btn2's row
assert btn3_pos[1] == btn2_pos[1] # same column
def test_add_widget_relative_invalid_position(layout_manager):
@@ -366,3 +385,74 @@ def test_shift_all_widgets_up_at_top_row(layout_manager):
layout_manager.shift_all_widgets(direction="up")
assert "Shifting widgets out of grid boundaries." in str(exc_info.value)
@pytest.mark.parametrize(
"test_id, position, shift_direction, additional_assertions",
[
(
"from_left",
"left",
"right",
[
# Additional assertions for the left test case
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn1_new_pos[1]
> btn1_pos[1], # column shifted right
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn2_new_pos[1]
> btn2_pos[1], # column shifted right
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn3_pos[1]
< btn2_new_pos[1], # btn3 is to the left of btn2
],
),
(
"from_right",
"right",
"right",
[
# Additional assertions for the right test case
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn3_pos[1]
> btn2_new_pos[1] # btn3 is to the right of btn2
],
),
],
)
def test_column_shift_when_adding_widget(
layout_manager, test_id, position, shift_direction, additional_assertions
):
"""Test that adding a widget to a column of widgets shifts the entire column appropriately."""
# Create a column of widgets
btn1 = QPushButton("Button 1")
btn2 = QPushButton("Button 2")
# Add btn1 at position (0, 1)
layout_manager.add_widget(btn1, row=0, col=1)
# Add btn2 below btn1
layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="bottom")
# Get the positions after initial setup
btn1_pos = layout_manager.widget_positions[btn1]
btn2_pos = layout_manager.widget_positions[btn2]
# Verify btn2 is below btn1 (same column)
assert btn1_pos[0] < btn2_pos[0] # btn2's row > btn1's row
assert btn1_pos[1] == btn2_pos[1] # same column
# Add a new button relative to btn2 with the specified position and shift_direction
btn3 = QPushButton("Button 3")
layout_manager.add_widget_relative(
btn3, reference_widget=btn2, position=position, shift_direction=shift_direction
)
# Get the updated positions
btn1_new_pos = layout_manager.widget_positions[btn1]
btn2_new_pos = layout_manager.widget_positions[btn2]
btn3_pos = layout_manager.widget_positions[btn3]
# Common assertions for both test cases
assert btn1_new_pos[1] == btn2_new_pos[1] # btn1 and btn2 still in same column
assert btn3_pos[0] == btn2_new_pos[0] # btn3 is in the same row as btn2
# Run additional assertions specific to each test case
for assertion in additional_assertions:
assertion(btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos)

View File

@@ -518,3 +518,156 @@ def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_acti
# Verify that fake_showMenu() was called.
assert call_flag, "Long press did not trigger showMenu() as expected."
# Additional tests for action/bundle removal
def test_remove_standalone_action(toolbar_fixture, icon_action, dummy_widget):
"""
Ensure that a standalone action is fully removed and no longer accessible.
"""
toolbar = toolbar_fixture
# Add the action and check it is present
toolbar.add_action("icon_action", icon_action, dummy_widget)
assert "icon_action" in toolbar.widgets
assert icon_action.action in toolbar.actions()
# Now remove it
toolbar.remove_action("icon_action")
# Action bookkeeping
assert "icon_action" not in toolbar.widgets
# QAction list
assert icon_action.action not in toolbar.actions()
# Attempting to hide / show it should raise
with pytest.raises(ValueError):
toolbar.hide_action("icon_action")
with pytest.raises(ValueError):
toolbar.show_action("icon_action")
def test_remove_action_from_bundle(
toolbar_fixture, dummy_widget, icon_action, material_icon_action
):
"""
Remove a single action that is part of a bundle and verify cleanup.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="test_bundle",
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
)
toolbar.add_bundle(bundle, dummy_widget)
# Initial assertions
assert "test_bundle" in toolbar.bundles
assert "icon_action" in toolbar.widgets
assert "material_action" in toolbar.widgets
# Remove one action from the bundle
toolbar.remove_action("icon_action")
# icon_action should be fully gone
assert "icon_action" not in toolbar.widgets
assert icon_action.action not in toolbar.actions()
# Bundle tracking should be updated
assert "icon_action" not in toolbar.bundles["test_bundle"]
# The other action must still exist
assert "material_action" in toolbar.widgets
assert material_icon_action.action in toolbar.actions()
def test_remove_last_action_from_bundle_removes_bundle(toolbar_fixture, dummy_widget, icon_action):
"""
Removing the final action from a bundle should delete the bundle entry itself.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(bundle_id="single_action_bundle", actions=[("only_action", icon_action)])
toolbar.add_bundle(bundle, dummy_widget)
# Sanity check
assert "single_action_bundle" in toolbar.bundles
assert "only_action" in toolbar.widgets
# Remove the sole action
toolbar.remove_action("only_action")
# Bundle should be gone
assert "single_action_bundle" not in toolbar.bundles
# QAction removed
assert icon_action.action not in toolbar.actions()
def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
"""
Ensure that removing a bundle deletes all its actions and separators.
"""
toolbar = toolbar_fixture
bundle = ToolbarBundle(
bundle_id="to_remove",
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
)
toolbar.add_bundle(bundle, dummy_widget)
# Confirm bundle presence
assert "to_remove" in toolbar.bundles
# Remove the whole bundle
toolbar.remove_bundle("to_remove")
# Bundle mapping gone
assert "to_remove" not in toolbar.bundles
# All actions gone
for aid, act in [("icon_action", icon_action), ("material_action", material_icon_action)]:
assert aid not in toolbar.widgets
assert act.action not in toolbar.actions()
def test_trigger_removed_action_raises(toolbar_fixture, icon_action, dummy_widget, qtbot):
"""
Add an action, connect a mock slot, then remove the action and verify that
attempting to trigger it afterwards raises RuntimeError (since the underlying
QAction has been deleted).
"""
toolbar = toolbar_fixture
# Add the action and connect a mock slot
toolbar.add_action("icon_action", icon_action, dummy_widget)
called = []
def mock_slot():
called.append(True)
icon_action.action.triggered.connect(mock_slot)
# Trigger once to confirm connection works
icon_action.action.trigger()
assert called == [True]
# Now remove the action
toolbar.remove_action("icon_action")
# Allow deleteLater event to process
qtbot.wait(50)
# The underlying C++ object should be deleted; triggering should raise
with pytest.raises(RuntimeError):
icon_action.action.trigger()
def test_remove_nonexistent_action(toolbar_fixture):
"""
Attempting to remove an action that does not exist should raise ValueError.
"""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
toolbar.remove_action("nonexistent_action")
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
def test_remove_nonexistent_bundle(toolbar_fixture):
"""
Attempting to remove a bundle that does not exist should raise ValueError.
"""
toolbar = toolbar_fixture
with pytest.raises(ValueError) as excinfo:
toolbar.remove_bundle("nonexistent_bundle")
assert "Bundle 'nonexistent_bundle' does not exist." in str(excinfo.value)

View File

@@ -1,9 +1,11 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from unittest.mock import MagicMock
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
from qtpy.QtCore import QModelIndex, QPoint, Qt
from bec_widgets.utils.forms_from_types.items import StrMetadataField
from bec_widgets.utils.widget_io import WidgetIO
@@ -540,6 +542,29 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1}
TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"}
TEST_TABLE_ENTRY = [["test key 1", "test value 1"], ["test key 2", "test value 2"]]
def test_scan_metadata_is_updated_even_without_default_form_changes(
scan_control: ScanControl, qtbot
):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
scan_control._metadata_form._additional_metadata._add_button.click()
qtbot.wait(100)
table_model = scan_control._metadata_form._additional_metadata._table_model
model_key = table_model.index(0, 0, QModelIndex())
table_model.setData(model_key, "test key 1", Qt.EditRole)
model_value = model_key.siblingAtColumn(1)
table_model.setData(model_value, "test value 1", Qt.EditRole)
assert scan_control._metadata_form._additional_metadata.dump_dict() == {
"test key 1": "test value 1"
}
assert scan_control._scan_metadata == {"sample_name": "", "test key 1": "test value 1"}
def test_scan_metadata_is_connected(scan_control):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
@@ -548,16 +573,28 @@ def test_scan_metadata_is_connected(scan_control):
assert isinstance(sample_name, StrMetadataField)
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = [
["test key 1", "test value 1"],
["test key 2", "test value 2"],
]
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
scan_control._metadata_form.validate_form()
assert scan_control._scan_metadata == {
"sample_name": "Test Sample",
"test key 1": "test value 1",
"test key 2": "test value 2",
}
assert scan_control._scan_metadata == TEST_MD
def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget()
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
scan_control._metadata_form.validate_form()
assert scan_control._scan_metadata == TEST_MD
scans = SimpleNamespace(grid_scan=MagicMock())
with (
patch.object(scan_control, "scans", scans),
patch.object(scan_control, "get_scan_parameters", lambda: ((), {})),
):
scan_control.run_scan()
scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):

View File

@@ -116,7 +116,7 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
def test_griditems_are_correct_class(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
_, components = metadata_widget
assert isinstance(components["sample_name"], StrMetadataField)
@@ -162,7 +162,7 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormIt
def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)

View File

@@ -1,4 +1,6 @@
import json
from types import SimpleNamespace
from unittest import mock
from unittest.mock import MagicMock
import numpy as np
@@ -19,6 +21,8 @@ from tests.unit_tests.client_mocks import (
from .conftest import create_widget
# pylint: disable=unexpected-keyword-arg
##################################################
# Waveform widget base functionality tests
##################################################
@@ -541,7 +545,14 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
msg = {"signals": {"async_device": {"value": [100, 200], "timestamp": [1001, 1002]}}}
metadata = {"async_update": {"max_shape": [None], "type": "add"}}
wf.on_async_readback(msg, metadata)
cb_info_ret = {"scan_id": wf.scan_id}
def ret_sender():
return SimpleNamespace(cb_info={"scan_id": wf.scan_id})
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
x_data, y_data = c.get_data()
assert len(x_data) == 5
@@ -553,7 +564,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
# instruction='replace'
msg2 = {"signals": {"async_device": {"value": [999], "timestamp": [555]}}}
metadata2 = {"async_update": {"max_shape": [None], "type": "replace"}}
wf.on_async_readback(msg2, metadata2)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg2, metadata2, _override_slot_params={"verify_sender": False})
x_data2, y_data2 = c.get_data()
np.testing.assert_array_equal(x_data2, [0])
@@ -568,7 +580,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
metadata = {
"async_update": {"max_shape": [None, waveform_shape], "index": 0, "type": "add_slice"}
}
wf.on_async_readback(msg, metadata)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
# Old data should be deleted since the slice_index did not match
x_data, y_data = c.get_data()
@@ -595,7 +608,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
metadata = {
"async_update": {"max_shape": [None, waveform_shape], "index": 0, "type": "add_slice"}
}
wf.on_async_readback(msg, metadata)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
x_data, y_data = c.get_data()
assert len(y_data) == waveform_shape
assert len(x_data) == waveform_shape
@@ -616,7 +630,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
}
}
metadata = {"async_update": {"type": "replace"}}
wf.on_async_readback(msg, metadata)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
x_data, y_data = c.get_data()
assert np.array_equal(y_data, np.array(range(waveform_shape)))

View File

@@ -0,0 +1,90 @@
from unittest import mock
import pytest
from qtpy.QtNetwork import QAuthenticator
from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry
from .client_mocks import mocked_client
@pytest.fixture
def console_widget(qtbot, mocked_client):
with mock.patch(
"bec_widgets.widgets.editors.web_console.web_console.subprocess"
) as mock_subprocess:
with mock.patch.object(_web_console_registry, "_wait_for_server_port"):
_web_console_registry._server_port = 12345
# Create the WebConsole widget
widget = WebConsole(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_web_console_widget_initialization(console_widget):
assert (
console_widget.page.url().toString()
== f"http://localhost:{_web_console_registry._server_port}"
)
def test_web_console_write(console_widget):
# Test the write method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.write("Hello, World!")
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
def test_web_console_write_no_return(console_widget):
# Test the write method with send_return=False
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.write("Hello, World!", send_return=False)
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
assert mock_run_js.call_count == 1
def test_web_console_send_return(console_widget):
# Test the send_return method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.send_return()
script = mock_run_js.call_args[0][0]
assert "new KeyboardEvent('keypress', {charCode: 13})" in script
assert mock_run_js.call_count == 1
def test_web_console_send_ctrl_c(console_widget):
# Test the send_ctrl_c method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.send_ctrl_c()
script = mock_run_js.call_args[0][0]
assert "new KeyboardEvent('keypress', {charCode: 3})" in script
assert mock_run_js.call_count == 1
def test_web_console_authenticate(console_widget):
# Test the _authenticate method
token = _web_console_registry._token
mock_auth = mock.MagicMock(spec=QAuthenticator)
console_widget._authenticate(None, mock_auth)
mock_auth.setUser.assert_called_once_with("user")
mock_auth.setPassword.assert_called_once_with(token)
def test_web_console_registry_wait_for_server_port():
# Test the _wait_for_server_port method
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"]
_web_console_registry._wait_for_server_port()
assert _web_console_registry._server_port == 12345
def test_web_console_registry_wait_for_server_port_timeout():
# Test the _wait_for_server_port method with timeout
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
with pytest.raises(TimeoutError):
_web_console_registry._wait_for_server_port(timeout=0.1)