mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
71 Commits
v2.38.2
...
7ea9ab5175
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea9ab5175 | ||
| b72f0dc6e8 | |||
|
|
cb9d429884 | ||
| 0a80bd0a92 | |||
|
|
9bc9d355e2 | ||
| 7d5e702a11 | |||
| 40cbf7fe4f | |||
|
|
7b287c45f2 | ||
| c9455672b5 | |||
|
|
7f06375f9d | ||
| d00d786399 | |||
| a4c465dcaf | |||
|
|
d0e94d0da4 | ||
| bb3cea7fe8 | |||
|
|
3c6aa8e138 | ||
| 198684c65d | |||
| 617f2df2af | |||
|
|
ef83287126 | ||
| d5e6f095fe | |||
| b10efc0f40 | |||
| 44b1dbf911 | |||
|
|
e9d381a18a | ||
|
|
b005542df3 | ||
| 13a9175ba5 | |||
|
|
3f8e60a14f | ||
| 6bc1c3c5f1 | |||
|
|
9f91eb2e08 | ||
| 1e19092319 | |||
| 96664c3923 | |||
|
|
741ca2fd8a | ||
| 3941050883 | |||
|
|
1d746c6829 | ||
| ef27de40ce | |||
| 37df95ead8 | |||
| c87a6cfce9 | |||
| 3d807eaa63 | |||
| 28ac9c5cc3 | |||
| 1dd20d5986 | |||
|
|
13299aeeb3 | ||
| d681ba538b | |||
| 2bf489600e | |||
| 7e88a002b6 | |||
| 20a59af648 | |||
| 540cfc37be | |||
| e59f27a22d | |||
| df8065ea40 | |||
| 2f3dc2ce6b | |||
| a006f95f21 | |||
| 8111a4a21b | |||
| 962ab774e6 | |||
| 2f798be7b0 | |||
| 5a5d32312b | |||
| 0844a9e119 | |||
| db7dd4f8d4 | |||
| f083dff612 | |||
| 4be70580a6 | |||
| d19001c94e | |||
| f25f86522f | |||
|
|
948283bc13 | ||
| 50696bce4c | |||
|
|
1d988a4c57 | ||
| 565c0bd1e7 | |||
| 975404f483 | |||
|
|
165e5e7d84 | ||
| 108ddae6ca | |||
|
|
9737acad58 | ||
| 65bc5f5421 | |||
| 475ca9f2d8 | |||
| bbb5fc6ce1 | |||
| b1b6c5e6a5 | |||
| 3e339348dd |
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
2
.github/workflows/pytest-matrix.yml
vendored
2
.github/workflows/pytest-matrix.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
env:
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
|
||||
8
.github/workflows/stale-issues.yml
vendored
8
.github/workflows/stale-issues.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
stale-issue-message: 'This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
days-before-stale: 120
|
||||
days-before-close: 14
|
||||
|
||||
289
.gitlab-ci.yml
289
.gitlab-ci.yml
@@ -1,289 +0,0 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official language image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python/tags/
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
BEC_CORE_BRANCH:
|
||||
description: bec branch
|
||||
value: main
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: ophyd_devices branch
|
||||
value: main
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
CHECK_PKG_VERSIONS:
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
value: 0
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
- if: $CI_PIPELINE_SOURCE == "pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v,--random-order,tests/unit_tests"
|
||||
pip_args: ".[dev]"
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-repos: &install-repos
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
|
||||
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
|
||||
fi
|
||||
|
||||
formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- 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:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
allow_failure: true
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
# Fail the job if the pylint score is below 9
|
||||
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
generate-client-check:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- bw-generate-cli --target bec_widgets
|
||||
# if there are changes in the generated files, fail the job
|
||||
- git diff --exit-code
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,$QT_PCKG]
|
||||
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3:25.1.1-2
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- cd ./bec
|
||||
- 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
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths:
|
||||
- ./logs/*.log
|
||||
expire_in: 1 week
|
||||
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
needs: ["tests"]
|
||||
script:
|
||||
- git config --global user.name "ci_update_bot"
|
||||
- git config --global user.email "ci_update_bot@bec.ch"
|
||||
- git checkout "$CI_COMMIT_REF_NAME"
|
||||
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
|
||||
|
||||
# delete all local tags
|
||||
- git tag -l | xargs git tag -d
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- semantic-release -vv version
|
||||
|
||||
# check if any artifacts were created
|
||||
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
|
||||
- semantic-release publish
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
|
||||
pages:
|
||||
stage: Deploy
|
||||
needs: ["semver"]
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
script:
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
@@ -52,7 +52,7 @@ persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.10
|
||||
py-version=3.11
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
283
CHANGELOG.md
283
CHANGELOG.md
@@ -1,6 +1,289 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.45.6 (2025-11-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **curve**: Update dap curves if data are set manually
|
||||
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
|
||||
|
||||
|
||||
## v2.45.5 (2025-11-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove ghost widgets in scan metadata
|
||||
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
|
||||
|
||||
|
||||
## v2.45.4 (2025-11-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Removed hiding scan progressbar animation
|
||||
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
|
||||
|
||||
- **web_links**: Fixed link to bec widget issues from gitlab to github
|
||||
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
|
||||
|
||||
|
||||
## v2.45.3 (2025-11-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **fakeredis**: Add support for additional args
|
||||
([`c945567`](https://github.com/bec-project/bec_widgets/commit/c9455672b58b9df101ccd0d80a169bdf6c707f34))
|
||||
|
||||
|
||||
## v2.45.2 (2025-11-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **test**: Removed duplicate test in crosshair
|
||||
([`d00d786`](https://github.com/bec-project/bec_widgets/commit/d00d786399bca516b8030b9de881b674140bf439))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyqtgraph pin to 0.13.7
|
||||
([`a4c465d`](https://github.com/bec-project/bec_widgets/commit/a4c465dcaf8cb03962dec1e360b7b832a9a5c780))
|
||||
|
||||
|
||||
## v2.45.1 (2025-11-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Async_readback can accept 0D data
|
||||
([`bb3cea7`](https://github.com/bec-project/bec_widgets/commit/bb3cea7fe800cd5375de5351a72e0944dc86861f))
|
||||
|
||||
|
||||
## v2.45.0 (2025-11-10)
|
||||
|
||||
### Chores
|
||||
|
||||
- Add third-party license notice
|
||||
([`617f2df`](https://github.com/bec-project/bec_widgets/commit/617f2df2af41db7692c42d0e10bce4968f36fb94))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Dap curve can be attached to custom and history curves
|
||||
([`198684c`](https://github.com/bec-project/bec_widgets/commit/198684c65d9565e8985156b426b8ef98dcc687cc))
|
||||
|
||||
|
||||
## v2.44.0 (2025-11-05)
|
||||
|
||||
### Chores
|
||||
|
||||
- Update stale issue and PR settings to 120 days
|
||||
([`e9d381a`](https://github.com/bec-project/bec_widgets/commit/e9d381a18a425727216f035ecccdad25f3189608))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Readme rewritten
|
||||
([`44b1dbf`](https://github.com/bec-project/bec_widgets/commit/44b1dbf911f43dbde4286e2ea541c480f7b834be))
|
||||
|
||||
### Features
|
||||
|
||||
- **plot_base**: Invert x/y axis
|
||||
([`b10efc0`](https://github.com/bec-project/bec_widgets/commit/b10efc0f400fe36f7cb0d5998214d50943934d7b))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **plot_base**: Consolidated user access for the PlotBase
|
||||
([`d5e6f09`](https://github.com/bec-project/bec_widgets/commit/d5e6f095fe60223972235acd3ea68389aa7a1a14))
|
||||
|
||||
|
||||
## v2.43.0 (2025-10-30)
|
||||
|
||||
### Features
|
||||
|
||||
- Add pdf viewer widget
|
||||
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
|
||||
|
||||
|
||||
## v2.42.1 (2025-10-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc_server**: Raise window, even if minimized
|
||||
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
|
||||
|
||||
|
||||
## v2.42.0 (2025-10-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **image_roi**: Enhance get_coordinates to include rectangle center and dimensions
|
||||
([`96664c3`](https://github.com/bec-project/bec_widgets/commit/96664c3923737df0b09aa8f35df388f9fd630b55))
|
||||
|
||||
- **positioner_box_2d**: Added properties to enable/disable vertical and horizontal controls
|
||||
([`1e19092`](https://github.com/bec-project/bec_widgets/commit/1e190923196f8b28c92dfdd83b9ce90873dd792d))
|
||||
|
||||
|
||||
## v2.41.1 (2025-10-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dependencies**: Bec lib versions fixed
|
||||
([`3941050`](https://github.com/bec-project/bec_widgets/commit/3941050883a791f800ab7178af2435ac14f837b6))
|
||||
|
||||
|
||||
## v2.41.0 (2025-10-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi**: Delete button added to compact version
|
||||
([`ef27de4`](https://github.com/bec-project/bec_widgets/commit/ef27de40ceee8375d95a0f3a8e451b7d05d0ae2c))
|
||||
|
||||
- **image_roi**: Rois can be removed with right click context menu
|
||||
([`37df95e`](https://github.com/bec-project/bec_widgets/commit/37df95ead8d6a07a6c5794a97a486d9f380004cc))
|
||||
|
||||
### Build System
|
||||
|
||||
- **bec_lib**: Version bump to 3.69.3
|
||||
([`28ac9c5`](https://github.com/bec-project/bec_widgets/commit/28ac9c5cc369bdfa712c70c45591243631c65066))
|
||||
|
||||
### Features
|
||||
|
||||
- **image_roi_tree**: Compact mode added
|
||||
([`c87a6cf`](https://github.com/bec-project/bec_widgets/commit/c87a6cfce9c36588b32f5279e63072bc2646c36f))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **serializer**: Upgrade to new serializer interface
|
||||
([`3d807ea`](https://github.com/bec-project/bec_widgets/commit/3d807eaa63980fd2bb11661696c4d8548fffde8c))
|
||||
|
||||
### Testing
|
||||
|
||||
- **deviceconfig-form-update**: Add onFailure default to test
|
||||
([`1dd20d5`](https://github.com/bec-project/bec_widgets/commit/1dd20d5986485f3bfe7ee02596ca23027ec4b756))
|
||||
|
||||
|
||||
## v2.40.0 (2025-10-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **curve_tree**: Fetching scan numbers directly from the bec client
|
||||
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
|
||||
|
||||
- **curve_tree**: Safeguard fetching scan numbers from BEC client
|
||||
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
|
||||
|
||||
- **curve_tree**: Scans are always fetched by scan ids
|
||||
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
|
||||
|
||||
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
|
||||
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
|
||||
|
||||
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
|
||||
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
|
||||
|
||||
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
|
||||
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
|
||||
|
||||
- **waveform**: Safeguard added to the fetching history data
|
||||
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
|
||||
|
||||
- **waveform**: Safeguard for _scan_history_closed
|
||||
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
|
||||
|
||||
- **waveform**: Safeguard for if scan_item is a list
|
||||
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
|
||||
|
||||
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
|
||||
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
|
||||
|
||||
- **waveform**: X_data checked with is scalar instead of len()
|
||||
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
|
||||
|
||||
### Build System
|
||||
|
||||
- **bec_lib**: Bec_lib dependency raised to 3.68
|
||||
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
|
||||
|
||||
### Chores
|
||||
|
||||
- Add dependabot config
|
||||
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: New type of curve - history curve
|
||||
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **test_waveform**: Test waveform renamed
|
||||
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
|
||||
|
||||
- **waveform**: Separate method to fetch scan item from history
|
||||
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
|
||||
|
||||
### Testing
|
||||
|
||||
- **conftest**: Suppress_message_box for error popups fixture autouse True
|
||||
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
|
||||
|
||||
- **plotting_framework_e2e**: Fetching history curve
|
||||
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
|
||||
|
||||
- **waveform,curve_tree**: Test extended to cover history curve behaviour
|
||||
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
|
||||
|
||||
|
||||
## v2.39.1 (2025-10-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Explicitly pass the cached readout flag
|
||||
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
|
||||
|
||||
|
||||
## v2.39.0 (2025-09-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc**: Fix hide/show
|
||||
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
|
||||
|
||||
### Features
|
||||
|
||||
- **rpc_base**: Windows can be raised to front from CLI
|
||||
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
|
||||
|
||||
|
||||
## v2.38.4 (2025-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image**: Add support for specifying preview signals through cli
|
||||
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
|
||||
|
||||
|
||||
## v2.38.3 (2025-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **connector**: Only flush pending events
|
||||
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
|
||||
|
||||
- **ringprogressbar**: Fix client signature
|
||||
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
|
||||
|
||||
- **ringprogressbar**: Various fixes and improvements
|
||||
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
|
||||
|
||||
### Chores
|
||||
|
||||
- Deprecate 3.10, add 3.13
|
||||
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
|
||||
|
||||
### Testing
|
||||
|
||||
- **ringprogressbar**: Extend e2e test
|
||||
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
|
||||
|
||||
|
||||
## v2.38.2 (2025-09-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
213
README.md
213
README.md
@@ -1,81 +1,200 @@
|
||||
# BEC Widgets
|
||||

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
|
||||
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
|
||||
plugin system.
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
## Highlights
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
|
||||
a single line of code**. Just launch, drag widgets, and do your experiment.
|
||||
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: drag‑dock, tab,
|
||||
split, and export profiles/workspaces for reuse.
|
||||
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
|
||||
`BECIPythonClient`.
|
||||
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
|
||||
with the custom BEC loader for a zero‑glue workflow.
|
||||
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
|
||||
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
|
||||
acquisition history.
|
||||
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
|
||||
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
|
||||
or override behavior as needed.
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Features](#features)
|
||||
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
|
||||
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
|
||||
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
|
||||
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
|
||||
- [Widget Library](#widget-library)
|
||||
- [Documentation](#documentation)
|
||||
- [License](#license)
|
||||
|
||||
## Installation
|
||||
|
||||
Use any of the following setups:
|
||||
|
||||
### Stable release
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
pip install bec_widgets
|
||||
```
|
||||
|
||||
### From source (recommended for development)
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
git clone https://github.com/bec-project/bec_widgets.git
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyside6]
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
## Features
|
||||
|
||||
### 1. Dock area interface: build GUIs in seconds
|
||||
|
||||
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
|
||||
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
|
||||
operate immediately and refine later with RPC or Designer if needed.
|
||||
|
||||

|
||||
|
||||
### 2. Qt Designer plugins + BEC Launcher (no glue)
|
||||
|
||||
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
|
||||
visually in Designer, save a `.ui`, then launch it with
|
||||
the **BEC Launcher**—no glue code. Widgets auto‑connect to BEC/Redis on startup, so your UI is operational immediately.
|
||||
|
||||

|
||||
|
||||
### 3. Robust RPC from CLI & remote scripting
|
||||
|
||||
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
|
||||
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
|
||||
active devices, topics)—so your UI can be heavily automated.
|
||||
|
||||
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
|
||||
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
|
||||
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
|
||||
actions,switch profiles, open diagnostic views, or start specific scans.
|
||||
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
|
||||
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
|
||||
|
||||

|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
|
||||
Designer plugin that are immediately usable with your BEC setup. Widgets
|
||||
come online with live BEC/Redis wiring out of the box. 
|
||||
|
||||
<details>
|
||||
<summary> View code: Example Widget </summary>
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
|
||||
class SimpleMotorWidget(BECWidget, QWidget):
|
||||
USER_ACCESS = ["move"]
|
||||
|
||||
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.motor_name = motor_name
|
||||
self.step = float(step)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.value_label = QLabel(f"{self.motor_name}: —")
|
||||
self.btn_left = QPushButton("◀︎ -5")
|
||||
self.btn_right = QPushButton("+5 ▶︎")
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.btn_left)
|
||||
row.addWidget(self.btn_right)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.addWidget(self.value_label)
|
||||
col.addLayout(row)
|
||||
|
||||
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
|
||||
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_readback(self, data: dict, meta: dict):
|
||||
current_value = data.get("signals").get(self.motor_name).get('value')
|
||||
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
|
||||
|
||||
@Slot(str, float)
|
||||
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
|
||||
if direction == "left":
|
||||
self.dev[self.motor_name].move(-step, relative=True)
|
||||
else:
|
||||
self.dev[self.motor_name].move(step, relative=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = SimpleMotorWidget(motor_name="samx", step=5.0)
|
||||
w.setWindowTitle("MotorJogWidget")
|
||||
w.resize(280, 90)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Widget Library
|
||||
|
||||
A large and growing catalog—plug, configure, run:
|
||||
|
||||
### Plotting
|
||||
|
||||
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
|
||||
history data.
|
||||
|
||||
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
|
||||
|
||||
### Scan orchestration and motion control.
|
||||
|
||||
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
|
||||
Positioner boxes and tweak controls handle precise moves, homing, and calibration for day‑to‑day alignment.
|
||||
|
||||
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
28
THIRD-PARTY-LICENCES
Normal file
28
THIRD-PARTY-LICENCES
Normal file
@@ -0,0 +1,28 @@
|
||||
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
|
||||
|
||||
Core Dependencies:
|
||||
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
|
||||
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
|
||||
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
|
||||
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
|
||||
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
|
||||
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
|
||||
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
|
||||
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
|
||||
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
|
||||
|
||||
|
||||
Additional Dependencies (Testing/Development):
|
||||
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
|
||||
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
|
||||
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
|
||||
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
|
||||
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
|
||||
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
|
||||
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
|
||||
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
|
||||
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
|
||||
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
|
||||
|
||||
@@ -45,6 +45,7 @@ _Widgets = {
|
||||
"MonacoWidget": "MonacoWidget",
|
||||
"MotorMap": "MotorMap",
|
||||
"MultiWaveform": "MultiWaveform",
|
||||
"PdfViewerWidget": "PdfViewerWidget",
|
||||
"PositionIndicator": "PositionIndicator",
|
||||
"PositionerBox": "PositionerBox",
|
||||
"PositionerBox2D": "PositionerBox2D",
|
||||
@@ -1204,6 +1205,12 @@ class EllipticalROI(RPCBase):
|
||||
class Heatmap(RPCBase):
|
||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -1391,6 +1398,29 @@ class Heatmap(RPCBase):
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -1419,6 +1449,48 @@ class Heatmap(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@x_log.setter
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@y_log.setter
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@legend_label_size.setter
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
@@ -1496,20 +1568,6 @@ class Heatmap(RPCBase):
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def autorange(self) -> "bool":
|
||||
@@ -1749,6 +1807,12 @@ class Heatmap(RPCBase):
|
||||
class Image(RPCBase):
|
||||
"""Image widget for displaying 2D data."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -1936,6 +2000,29 @@ class Image(RPCBase):
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -1964,6 +2051,48 @@ class Image(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@x_log.setter
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@y_log.setter
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@legend_label_size.setter
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
@@ -2041,20 +2170,6 @@ class Image(RPCBase):
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def autorange(self) -> "bool":
|
||||
@@ -2218,7 +2333,7 @@ class Image(RPCBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to use for the image.
|
||||
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
|
||||
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
@@ -2594,6 +2709,12 @@ class MonacoWidget(RPCBase):
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -2795,6 +2916,15 @@ class MotorMap(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -2865,6 +2995,20 @@ class MotorMap(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -2992,6 +3136,12 @@ class MotorMap(RPCBase):
|
||||
class MultiWaveform(RPCBase):
|
||||
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -3193,6 +3343,15 @@ class MultiWaveform(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -3421,6 +3580,137 @@ class MultiWaveform(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class PdfViewerWidget(RPCBase):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
@rpc_call
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
Load a PDF file into the viewer.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the PDF file to load.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def zoom_in(self):
|
||||
"""
|
||||
Zoom in the PDF view.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def zoom_out(self):
|
||||
"""
|
||||
Zoom out the PDF view.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def fit_to_width(self):
|
||||
"""
|
||||
Fit PDF to width.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def fit_to_page(self):
|
||||
"""
|
||||
Fit PDF to page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def reset_zoom(self):
|
||||
"""
|
||||
Reset zoom to 100% (1.0 factor).
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def previous_page(self):
|
||||
"""
|
||||
Go to previous page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def next_page(self):
|
||||
"""
|
||||
Go to next page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def toggle_continuous_scroll(self, checked: bool):
|
||||
"""
|
||||
Toggle between single page and continuous scroll mode.
|
||||
|
||||
Args:
|
||||
checked (bool): True to enable continuous scroll, False for single page mode.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def page_spacing(self):
|
||||
"""
|
||||
Get the spacing between pages in continuous scroll mode.
|
||||
"""
|
||||
|
||||
@page_spacing.setter
|
||||
@rpc_call
|
||||
def page_spacing(self):
|
||||
"""
|
||||
Get the spacing between pages in continuous scroll mode.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def side_margins(self):
|
||||
"""
|
||||
Get the horizontal margins (side spacing) around the PDF content.
|
||||
"""
|
||||
|
||||
@side_margins.setter
|
||||
@rpc_call
|
||||
def side_margins(self):
|
||||
"""
|
||||
Get the horizontal margins (side spacing) around the PDF content.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def go_to_first_page(self):
|
||||
"""
|
||||
Go to the first page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def go_to_last_page(self):
|
||||
"""
|
||||
Go to the last page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def jump_to_page(self, page_number: int):
|
||||
"""
|
||||
Jump to a specific page number (1-based index).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def current_page(self):
|
||||
"""
|
||||
Get the current page number (1-based index).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def current_file_path(self):
|
||||
"""
|
||||
Get the current PDF file path.
|
||||
"""
|
||||
|
||||
@current_file_path.setter
|
||||
@rpc_call
|
||||
def current_file_path(self):
|
||||
"""
|
||||
Get the current PDF file path.
|
||||
"""
|
||||
|
||||
|
||||
class PositionIndicator(RPCBase):
|
||||
"""Display a position within a defined range, e.g. motor limits."""
|
||||
|
||||
@@ -3534,6 +3824,34 @@ class PositionerBox2D(RPCBase):
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_controls_hor(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for horizontal control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@enable_controls_hor.setter
|
||||
@rpc_call
|
||||
def enable_controls_hor(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for horizontal control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_controls_ver(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for vertical control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@enable_controls_ver.setter
|
||||
@rpc_call
|
||||
def enable_controls_ver(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for vertical control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
|
||||
class PositionerControlLine(RPCBase):
|
||||
"""A widget that controls a single device."""
|
||||
@@ -3653,8 +3971,8 @@ class RectangularROI(RPCBase):
|
||||
@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.
|
||||
Returns the coordinates of a rectangle's corners, rectangle center and dimensions.
|
||||
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
|
||||
@@ -3662,7 +3980,7 @@ class RectangularROI(RPCBase):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
|
||||
@@ -3880,7 +4198,7 @@ class RingProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_precision(self, precision: "int", bar_index: "int" = None):
|
||||
def set_precision(self, precision: "int", bar_index: "int | None" = None):
|
||||
"""
|
||||
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
|
||||
|
||||
@@ -4042,6 +4360,12 @@ class ScatterCurve(RPCBase):
|
||||
|
||||
|
||||
class ScatterWaveform(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -4243,6 +4567,15 @@ class ScatterWaveform(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -4661,14 +4994,10 @@ class VSCodeEditor(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
def remove(self):
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -4872,6 +5201,15 @@ class Waveform(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -4900,15 +5238,6 @@ class Waveform(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
@@ -4972,6 +5301,16 @@ class Waveform(RPCBase):
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
@@ -5079,6 +5418,8 @@ class Waveform(RPCBase):
|
||||
color: "str | None" = None,
|
||||
label: "str | None" = None,
|
||||
dap: "str | None" = None,
|
||||
scan_id: "str | None" = None,
|
||||
scan_number: "int | None" = None,
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
@@ -5098,9 +5439,13 @@ class Waveform(RPCBase):
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
|
||||
|
||||
Returns:
|
||||
Curve: The curve object.
|
||||
@@ -5116,11 +5461,12 @@ class Waveform(RPCBase):
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
Create a new DAP curve referencing the existing curve `device_label`, with the
|
||||
data processing model `dap_name`. DAP curves can be attached to curves that
|
||||
originate from live devices, history, or fully custom data sources.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
@@ -5143,11 +5489,11 @@ class Waveform(RPCBase):
|
||||
def update_with_scan_history(self, scan_index: "int" = None, scan_id: "str" = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scan_id or scan_index.
|
||||
If both arguments are provided, scan_id takes precedence and scan_index is ignored.
|
||||
|
||||
Args:
|
||||
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index (scan number) of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
|
||||
@@ -285,6 +285,18 @@ class BECGuiClient(RPCBase):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
def raise_window(self, wait: bool = True) -> None:
|
||||
"""
|
||||
Bring GUI windows to the front.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
@@ -443,8 +455,8 @@ class BECGuiClient(RPCBase):
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
@@ -454,11 +466,24 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _do_raise_all(self):
|
||||
"""Bring GUI windows to the front."""
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
return self._do_raise_all()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
|
||||
@@ -202,6 +202,11 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
|
||||
def raise_window(self):
|
||||
"""Bring this widget (or its container) to the front."""
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
@@ -225,6 +230,12 @@ class RPCBase:
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
gui_id = obj.get("container_proxy") # type: ignore
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
|
||||
@@ -55,7 +55,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
# "btn6": self.btn6,
|
||||
# "pb": self.pb,
|
||||
# "pi": self.pi,
|
||||
# "wf": self.wf,
|
||||
"wf": self.wf,
|
||||
# "scatter": self.scatter,
|
||||
# "scatter_mi": self.scatter,
|
||||
# "mwf": self.mwf,
|
||||
@@ -105,12 +105,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
# self.btn5 = QPushButton("Button 5")
|
||||
# self.btn6 = QPushButton("Button 6")
|
||||
#
|
||||
# fifth_tab = QWidget()
|
||||
# fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
# self.wf = Waveform()
|
||||
# fifth_tab_layout.addWidget(self.wf)
|
||||
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
# tab_widget.setCurrentIndex(4)
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wf = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wf)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
#
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
|
||||
@@ -173,7 +173,7 @@ class FakePositioner(BECPositioner):
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self):
|
||||
def read(self, cached=False):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
|
||||
@@ -213,7 +213,7 @@ class BECConnector:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.processEvents()
|
||||
QApplication.sendPostedEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
|
||||
@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(item.name)
|
||||
label = QLabel(parent=self._form_grid, text=item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
gl = self._form_grid.layout()
|
||||
while w := gl.takeAt(0):
|
||||
w = w.widget()
|
||||
if hasattr(w, "teardown"):
|
||||
w.teardown()
|
||||
w.deleteLater()
|
||||
self._form_grid_container.layout().removeWidget(self._form_grid)
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
|
||||
@@ -4,7 +4,17 @@ import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
from typing import (
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
OrderedDict,
|
||||
TypeVar,
|
||||
get_args,
|
||||
)
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -199,6 +209,12 @@ class DynamicFormItem(QWidget):
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
def teardown(self):
|
||||
self._layout.deleteLater()
|
||||
self._layout.removeWidget(self._main_widget)
|
||||
self._main_widget.deleteLater()
|
||||
self._main_widget = None
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
@@ -350,11 +366,13 @@ class DictFormItem(DynamicFormItem):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple):
|
||||
# TODO: this should be generic but not supported in 3.10
|
||||
item: type[int | float | str]
|
||||
_IW = TypeVar("_IW", bound=int | float | str)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
|
||||
item: type[_IW]
|
||||
widget: type[QWidget]
|
||||
default: int | float | str
|
||||
default: _IW
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -10,7 +11,7 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -128,16 +129,44 @@ class RPCServer:
|
||||
# Run with rpc registry broadcast, but only once
|
||||
with RPCRegister.delayed_broadcast():
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
# and call raise_() and activateWindow()
|
||||
# This forces gnome to raise the window even if focus stealing is prevented
|
||||
# 3. Flag for stay on top is removed again to restore the original window state
|
||||
# 4. Finally, we call show() to ensure the window is visible
|
||||
|
||||
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
|
||||
target_state = state | Qt.WindowActive
|
||||
if state & Qt.WindowMinimized:
|
||||
target_state &= ~Qt.WindowMinimized
|
||||
obj.setWindowState(target_state)
|
||||
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
|
||||
obj.showNormal()
|
||||
if hasattr(obj, "raise_"):
|
||||
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
|
||||
obj.raise_()
|
||||
if hasattr(obj, "activateWindow"):
|
||||
obj.activateWindow()
|
||||
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
@@ -229,6 +258,8 @@ class RPCServer:
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
logger.info(f"Waiting for {connector} to be registered...")
|
||||
time.sleep(0.1)
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
from bec_lib.codecs import BECCodec
|
||||
from bec_lib.serialization import msgpack
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
|
||||
class QPointFEncoder(BECCodec):
|
||||
obj_type = QPointF
|
||||
|
||||
@staticmethod
|
||||
def encode(obj: QPointF) -> list[float]:
|
||||
"""Encode a QPointF object to a list of floats."""
|
||||
return [obj.x(), obj.y()]
|
||||
|
||||
@staticmethod
|
||||
def decode(type_name: str, data: list[float]) -> list[float]:
|
||||
"""No-op function since QPointF is encoded as a list of floats."""
|
||||
return data
|
||||
|
||||
|
||||
def register_serializer_extension():
|
||||
"""
|
||||
Register the serializer extension for the BECConnector.
|
||||
"""
|
||||
if not module_is_registered("bec_widgets.utils.serialization"):
|
||||
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
|
||||
|
||||
|
||||
def module_is_registered(module_name: str) -> bool:
|
||||
"""
|
||||
Check if the module is registered in the encoder.
|
||||
|
||||
Args:
|
||||
module_name (str): The name of the module to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the module is registered, False otherwise.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
for enc in msgpack._encoder:
|
||||
if enc[0].__module__ == module_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def encode_qpointf(obj):
|
||||
"""
|
||||
Encode a QPointF object to a list of floats. As this is mostly used for sending
|
||||
data to the client, it is not necessary to convert it back to a QPointF object.
|
||||
"""
|
||||
if isinstance(obj, QPointF):
|
||||
return [obj.x(), obj.y()]
|
||||
return obj
|
||||
|
||||
|
||||
def decode_qpointf(obj):
|
||||
"""
|
||||
no-op function since QPointF is encoded as a list of floats.
|
||||
"""
|
||||
return obj
|
||||
if not msgpack.is_registered(QPointF):
|
||||
msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)
|
||||
|
||||
@@ -12,4 +12,4 @@ class BECWebLinksMixin:
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
|
||||
SCAN_PROGRESS_HEIGHT = 12 # px
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -201,8 +201,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_simple.show_remaining_time = False
|
||||
self._scan_progress_bar_simple.show_source_label = False
|
||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
self._scan_progress_bar_full = ScanProgressBar(self)
|
||||
self._scan_progress_hover = HoverWidget(
|
||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||
@@ -219,62 +219,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(separator)
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
|
||||
|
||||
# Set Size
|
||||
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
|
||||
|
||||
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
|
||||
|
||||
# Visibility logic
|
||||
self._scan_progress_bar_with_separator.hide()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
# Timer for hiding logic
|
||||
self._scan_progress_hide_timer = QTimer(self)
|
||||
self._scan_progress_hide_timer.setSingleShot(True)
|
||||
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
|
||||
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
|
||||
|
||||
# Show / hide behaviour
|
||||
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
|
||||
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
|
||||
|
||||
def _show_scan_progress_bar(self):
|
||||
if self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
if self._scan_progress_bar_with_separator.isVisible():
|
||||
return
|
||||
|
||||
# Make visible and reset width
|
||||
self._scan_progress_bar_with_separator.show()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
self._show_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._show_container_anim.setDuration(300)
|
||||
self._show_container_anim.setStartValue(0)
|
||||
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
|
||||
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
self._show_container_anim.start()
|
||||
|
||||
def _delay_hide_scan_progress_bar(self):
|
||||
"""Start the countdown to hide the scan progress bar."""
|
||||
if hasattr(self, "_scan_progress_hide_timer"):
|
||||
self._scan_progress_hide_timer.start()
|
||||
|
||||
def _animate_hide_scan_progress_bar(self):
|
||||
"""Shrink container to the right, then hide."""
|
||||
self._hide_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._hide_container_anim.setDuration(300)
|
||||
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
|
||||
self._hide_container_anim.setEndValue(0)
|
||||
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
|
||||
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
|
||||
self._hide_container_anim.start()
|
||||
|
||||
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
|
||||
"""
|
||||
Add a vertically centred separator to the status bar or just return it as a separate object.
|
||||
@@ -474,8 +420,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
# Timer cleanup
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
|
||||
########################################
|
||||
# Status bar widgets cleanup
|
||||
|
||||
@@ -88,7 +88,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
if not self._check_device_is_valid(device):
|
||||
return
|
||||
|
||||
data = self.dev[device].read()
|
||||
data = self.dev[device].read(cached=True)
|
||||
self._on_device_readback(
|
||||
device,
|
||||
self._device_ui_components(device),
|
||||
|
||||
@@ -34,7 +34,15 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
|
||||
USER_ACCESS = [
|
||||
"set_positioner_hor",
|
||||
"set_positioner_ver",
|
||||
"screenshot",
|
||||
"enable_controls_hor",
|
||||
"enable_controls_hor.setter",
|
||||
"enable_controls_ver",
|
||||
"enable_controls_ver.setter",
|
||||
]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
@@ -63,6 +71,8 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self._limits_hor = None
|
||||
self._limits_ver = None
|
||||
self._dialog = None
|
||||
self._enable_controls_hor = True
|
||||
self._enable_controls_ver = True
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self.init_ui()
|
||||
@@ -281,6 +291,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self.on_device_readback_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
)
|
||||
self._apply_controls_enabled("horizontal")
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_ver(self, old_device: str, new_device: str):
|
||||
@@ -300,6 +311,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self.on_device_readback_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
)
|
||||
self._apply_controls_enabled("vertical")
|
||||
|
||||
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
|
||||
if device == "horizontal":
|
||||
@@ -337,6 +349,25 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
if device == self.device_ver:
|
||||
return self._device_ui_components_hv("vertical")
|
||||
|
||||
def _apply_controls_enabled(self, axis: DeviceId):
|
||||
state = self._enable_controls_hor if axis == "horizontal" else self._enable_controls_ver
|
||||
if axis == "horizontal":
|
||||
widgets = [
|
||||
self.ui.tweak_increase_hor,
|
||||
self.ui.tweak_decrease_hor,
|
||||
self.ui.step_increase_hor,
|
||||
self.ui.step_decrease_hor,
|
||||
]
|
||||
else:
|
||||
widgets = [
|
||||
self.ui.tweak_increase_ver,
|
||||
self.ui.tweak_decrease_ver,
|
||||
self.ui.step_increase_ver,
|
||||
self.ui.step_decrease_ver,
|
||||
]
|
||||
for w in widgets:
|
||||
w.setEnabled(state)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
@@ -417,6 +448,26 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_ver.setValue(val)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_controls_hor(self) -> bool:
|
||||
"""Persisted switch for horizontal control buttons (tweak/step)."""
|
||||
return self._enable_controls_hor
|
||||
|
||||
@enable_controls_hor.setter
|
||||
def enable_controls_hor(self, value: bool):
|
||||
self._enable_controls_hor = value
|
||||
self._apply_controls_enabled("horizontal")
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_controls_ver(self) -> bool:
|
||||
"""Persisted switch for vertical control buttons (tweak/step)."""
|
||||
return self._enable_controls_ver
|
||||
|
||||
@enable_controls_ver.setter
|
||||
def enable_controls_ver(self, value: bool):
|
||||
self._enable_controls_ver = value
|
||||
self._apply_controls_enabled("vertical")
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
|
||||
@@ -85,7 +85,6 @@ class ScanMetadata(PydanticModelForm):
|
||||
def set_schema_from_scan(self, scan_name: str | None):
|
||||
self._scan_name = scan_name or ""
|
||||
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
||||
self.populate()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -26,6 +26,7 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.heatmap.settings.heatmap_setting import HeatmapSettings
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -83,39 +84,7 @@ class Heatmap(ImageBase):
|
||||
"""
|
||||
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
@@ -125,8 +94,6 @@ class Heatmap(ImageBase):
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Literal
|
||||
from typing import Literal, Sequence
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
@@ -22,6 +22,7 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -59,39 +60,7 @@ class Image(ImageBase):
|
||||
RPC = True
|
||||
ICON_NAME = "image"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
@@ -101,8 +70,6 @@ class Image(ImageBase):
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
@@ -307,7 +274,7 @@ class Image(ImageBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to use for the image.
|
||||
monitor(str|tuple|None): The name of the monitor to use for the image, or a tuple of (device, signal) for preview signals. If None or empty string, the current monitor will be disconnected.
|
||||
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
|
||||
color_map(str): The color map to use for the image.
|
||||
color_bar(str): The type of color bar to use. Options are "simple" or "full".
|
||||
@@ -322,10 +289,13 @@ class Image(ImageBase):
|
||||
if monitor is None or monitor == "":
|
||||
logger.warning(f"No monitor specified, cannot set image, old monitor is unsubscribed")
|
||||
return None
|
||||
if isinstance(monitor, tuple):
|
||||
|
||||
if isinstance(monitor, str):
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
elif isinstance(monitor, Sequence):
|
||||
self.entry_validator.validate_monitor(monitor[0])
|
||||
else:
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
raise ValueError(f"Invalid monitor type: {type(monitor)}")
|
||||
|
||||
self.set_image_update(monitor=monitor, type=monitor_type)
|
||||
if color_map is not None:
|
||||
@@ -347,7 +317,7 @@ class Image(ImageBase):
|
||||
if config.monitor is not None:
|
||||
for combo in (self.device_combo_box, self.dim_combo_box):
|
||||
combo.blockSignals(True)
|
||||
if isinstance(config.monitor, tuple):
|
||||
if isinstance(config.monitor, (list, tuple)):
|
||||
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
|
||||
else:
|
||||
self.device_combo_box.setCurrentText(config.monitor)
|
||||
@@ -452,7 +422,7 @@ class Image(ImageBase):
|
||||
"""
|
||||
|
||||
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
|
||||
if isinstance(monitor, tuple):
|
||||
if isinstance(monitor, (list, tuple)):
|
||||
device = self.dev[monitor[0]]
|
||||
signal = monitor[1]
|
||||
if len(monitor) == 3:
|
||||
@@ -520,7 +490,7 @@ class Image(ImageBase):
|
||||
Args:
|
||||
monitor(str|tuple): The name of the monitor to disconnect, or a tuple of (device, signal) for preview signals.
|
||||
"""
|
||||
if isinstance(monitor, tuple):
|
||||
if isinstance(monitor, (list, tuple)):
|
||||
if self.subscriptions["main"].source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -73,11 +73,16 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
- Children: type, line-width (spin box), coordinates (auto-updating).
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
image_widget (Image): The main Image widget that displays the ImageItem.
|
||||
Provides ``plot_item`` and owns an ROIController already.
|
||||
controller (ROIController, optional): Optionally pass an external controller.
|
||||
If None, the manager uses ``image_widget.roi_controller``.
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
compact (bool, optional): If True, use a compact mode with no tree view,
|
||||
only a toolbar with draw actions. Defaults to False.
|
||||
compact_orientation (str, optional): Orientation of the toolbar in compact mode.
|
||||
Either "vertical" or "horizontal". Defaults to "vertical".
|
||||
compact_color (str, optional): Color of the single active ROI in compact mode.
|
||||
"""
|
||||
|
||||
PLUGIN = False
|
||||
@@ -92,11 +97,18 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
parent: QWidget = None,
|
||||
image_widget: Image,
|
||||
controller: ROIController | None = None,
|
||||
compact: bool = False,
|
||||
compact_orientation: Literal["vertical", "horizontal"] = "vertical",
|
||||
compact_color: str = "#f0f0f0",
|
||||
):
|
||||
|
||||
super().__init__(
|
||||
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
)
|
||||
self.compact = compact
|
||||
self.compact_orient = compact_orientation
|
||||
self.compact_color = compact_color
|
||||
self.single_active_roi: BaseROI | None = None
|
||||
|
||||
if controller is None:
|
||||
# Use the controller already belonging to the Image widget
|
||||
@@ -112,22 +124,29 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self._init_toolbar()
|
||||
self._init_tree()
|
||||
if not self.compact:
|
||||
self._init_tree()
|
||||
else:
|
||||
self.tree = None
|
||||
|
||||
# connect controller
|
||||
self.controller.roiAdded.connect(self._on_roi_added)
|
||||
self.controller.roiRemoved.connect(self._on_roi_removed)
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
if not self.compact:
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
|
||||
# initial load
|
||||
for r in self.controller.rois:
|
||||
self._on_roi_added(r)
|
||||
|
||||
self.tree.collapseAll()
|
||||
if not self.compact:
|
||||
self.tree.collapseAll()
|
||||
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
|
||||
tb = self.toolbar = ModularToolBar(
|
||||
self, orientation=self.compact_orient if self.compact else "horizontal"
|
||||
)
|
||||
self._draw_actions: dict[str, MaterialIconAction] = {}
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
|
||||
@@ -157,6 +176,29 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
for mode, act in self._draw_actions.items():
|
||||
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
|
||||
|
||||
if self.compact:
|
||||
tb.components.add_safe(
|
||||
"compact_delete",
|
||||
MaterialIconAction("delete", "Delete Current Roi", checkable=False, parent=self),
|
||||
)
|
||||
bundle.add_action("compact_delete")
|
||||
tb.components.get_action("compact_delete").action.triggered.connect(
|
||||
lambda _: (
|
||||
self.controller.remove_roi(self.single_active_roi)
|
||||
if self.single_active_roi is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
tb.show_bundles(["roi_draw"])
|
||||
self.layout.addWidget(tb)
|
||||
|
||||
# ROI drawing state (needed even in compact mode)
|
||||
self._roi_draw_mode = None
|
||||
self._roi_start_pos = None
|
||||
self._temp_roi = None
|
||||
self.plot.scene().installEventFilter(self)
|
||||
return
|
||||
|
||||
# Expand/Collapse toggle
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
|
||||
@@ -327,13 +369,21 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self._set_roi_draw_mode(None)
|
||||
# register via controller
|
||||
self.controller.add_roi(final_roi)
|
||||
if self.compact:
|
||||
final_roi.line_color = self.compact_color
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# --------------------------------------------------------- controller slots
|
||||
def _on_roi_added(self, roi: BaseROI):
|
||||
if self.compact:
|
||||
roi.line_color = self.compact_color
|
||||
if self.single_active_roi is not None and self.single_active_roi is not roi:
|
||||
self.controller.remove_roi(self.single_active_roi)
|
||||
self.single_active_roi = roi
|
||||
return
|
||||
# check the global setting from the toolbar
|
||||
if self.lock_all_action.action.isChecked():
|
||||
if hasattr(self, "lock_all_action") and self.lock_all_action.action.isChecked():
|
||||
roi.movable = False
|
||||
# parent row with blank action column, name in ROI column
|
||||
parent = QTreeWidgetItem(self.tree, ["", "", ""])
|
||||
@@ -424,6 +474,10 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
roi.movable = not roi.movable
|
||||
|
||||
def _on_roi_removed(self, roi: BaseROI):
|
||||
if self.compact:
|
||||
if self.single_active_roi is roi:
|
||||
self.single_active_roi = None
|
||||
return
|
||||
item = self.roi_items.pop(roi, None)
|
||||
if item:
|
||||
idx = self.tree.indexOfTopLevelItem(item)
|
||||
@@ -449,8 +503,9 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.controller.remove_roi(roi)
|
||||
|
||||
def cleanup(self):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if hasattr(self, "cmap"):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if self.controller and hasattr(self.controller, "rois"):
|
||||
for roi in self.controller.rois: # disconnect all signals from ROIs
|
||||
try:
|
||||
@@ -491,8 +546,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Add the image widget on the left
|
||||
ml.addWidget(image_widget)
|
||||
|
||||
# ROI manager linked to that image
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
|
||||
# ROI manager linked to that image with compact mode
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget, compact=True)
|
||||
mgr.setFixedWidth(350)
|
||||
ml.addWidget(mgr)
|
||||
|
||||
|
||||
@@ -90,45 +90,7 @@ class MotorMap(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "my_location"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# motor_map specific
|
||||
"color",
|
||||
"color.setter",
|
||||
@@ -765,7 +727,7 @@ class MotorMap(PlotBase):
|
||||
float: Motor initial position.
|
||||
"""
|
||||
entry = self.entry_validator.validate_signal(name, None)
|
||||
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
|
||||
init_position = round(float(self.dev[name].read(cached=True)[entry]["value"]), precision)
|
||||
return init_position
|
||||
|
||||
def _sync_motor_map_selection_toolbar(self):
|
||||
|
||||
@@ -56,47 +56,7 @@ class MultiWaveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "ssid_chart"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
"highlighted_index.setter",
|
||||
|
||||
@@ -63,6 +63,50 @@ class UIMode(Enum):
|
||||
class PlotBase(BECWidget, QWidget):
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
BASE_USER_ACCESS = [
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
]
|
||||
USER_ACCESS = [*BECWidget.USER_ACCESS, *BASE_USER_ACCESS]
|
||||
|
||||
# Custom Signals
|
||||
property_changed = Signal(str, object)
|
||||
@@ -831,6 +875,40 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._apply_y_label()
|
||||
self.property_changed.emit("inner_axes", value)
|
||||
|
||||
@SafeProperty(bool, doc="Invert X axis.")
|
||||
def invert_x(self) -> bool:
|
||||
"""
|
||||
Invert X axis.
|
||||
"""
|
||||
return self.plot_item.vb.state.get("xInverted", False)
|
||||
|
||||
@invert_x.setter
|
||||
def invert_x(self, value: bool):
|
||||
"""
|
||||
Invert X axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.vb.invertX(value)
|
||||
|
||||
@SafeProperty(bool, doc="Invert Y axis.")
|
||||
def invert_y(self) -> bool:
|
||||
"""
|
||||
Invert Y axis.
|
||||
"""
|
||||
return self.plot_item.vb.state.get("yInverted", False)
|
||||
|
||||
@invert_y.setter
|
||||
def invert_y(self, value: bool):
|
||||
"""
|
||||
Invert Y axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.vb.invertY(value)
|
||||
|
||||
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
|
||||
def lock_aspect_ratio(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -174,6 +174,8 @@ class BaseROI(BECConnector):
|
||||
self.remove_scale_handles() # remove any existing handles from pyqtgraph.RectROI
|
||||
if movable:
|
||||
self.add_scale_handle() # add custom scale handles
|
||||
if hasattr(self, "sigRemoveRequested"):
|
||||
self.sigRemoveRequested.connect(self.remove)
|
||||
|
||||
def set_parent(self, parent: Image):
|
||||
"""
|
||||
@@ -556,8 +558,8 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
|
||||
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.
|
||||
Returns the coordinates of a rectangle's corners, rectangle center and dimensions.
|
||||
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
|
||||
@@ -565,13 +567,17 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
x_left, y_bottom, x_right, y_top = self._normalized_edges()
|
||||
width = x_right - x_left
|
||||
height = y_top - y_bottom
|
||||
cx = x_left + width / 2
|
||||
cy = y_bottom + height / 2
|
||||
|
||||
if typed:
|
||||
return {
|
||||
@@ -579,8 +585,19 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
"bottom_right": (x_right, y_bottom),
|
||||
"top_left": (x_left, y_top),
|
||||
"top_right": (x_right, y_top),
|
||||
"center_x": cx,
|
||||
"center_y": cy,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
|
||||
return (
|
||||
(x_left, y_bottom),
|
||||
(x_right, y_bottom),
|
||||
(x_left, y_top),
|
||||
(x_right, y_top),
|
||||
(cx, cy),
|
||||
(width, height),
|
||||
)
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
|
||||
@@ -44,47 +44,7 @@ class ScatterWaveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "scatter_plot"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
"color_map",
|
||||
|
||||
@@ -42,10 +42,15 @@ class CurveConfig(ConnectionConfig):
|
||||
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
|
||||
"solid", description="The style of the pen of the curve."
|
||||
)
|
||||
source: Literal["device", "dap", "custom"] = Field(
|
||||
source: Literal["device", "dap", "custom", "history"] = Field(
|
||||
"custom", description="The source of the curve."
|
||||
)
|
||||
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
|
||||
scan_id: str | None = Field(None, description="Scan ID to be used when `source` is 'history'.")
|
||||
scan_number: int | None = Field(
|
||||
None, description="Scan index to be used when `source` is 'history'."
|
||||
)
|
||||
current_x_mode: str | None = Field(None, description="The current x mode of the history curve.")
|
||||
parent_label: str | None = Field(
|
||||
None, description="The label of the parent plot, only relevant for dap curves."
|
||||
)
|
||||
@@ -199,8 +204,9 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
Raises:
|
||||
ValueError: If the source is not custom.
|
||||
"""
|
||||
if self.config.source == "custom":
|
||||
if self.config.source in ["custom", "history"]:
|
||||
self.setData(x, y)
|
||||
self.parent_item.request_dap_update.emit()
|
||||
else:
|
||||
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
|
||||
|
||||
|
||||
@@ -5,7 +5,34 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QValidator
|
||||
|
||||
|
||||
class ScanIndexValidator(QValidator):
|
||||
"""Validator to allow only 'live' or integer scan numbers from an allowed set."""
|
||||
|
||||
def __init__(self, allowed_scans: set[int] | None = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.allowed_scans = allowed_scans or set()
|
||||
|
||||
def validate(self, input_str: str, pos: int):
|
||||
# Accept empty or 'live'
|
||||
if input_str == "" or input_str == "live":
|
||||
return QValidator.State.Acceptable, input_str, pos
|
||||
# Allow partial editing of "live"
|
||||
if "live".startswith(input_str):
|
||||
return QValidator.State.Intermediate, input_str, pos
|
||||
# Accept integer only if present in the allowed set
|
||||
if input_str.isdigit():
|
||||
try:
|
||||
num = int(input_str)
|
||||
except ValueError:
|
||||
return QValidator.State.Invalid, input_str, pos
|
||||
if num in self.allowed_scans:
|
||||
return QValidator.State.Acceptable, input_str, pos
|
||||
return QValidator.State.Invalid, input_str, pos
|
||||
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
@@ -91,8 +118,60 @@ class CurveRow(QTreeWidgetItem):
|
||||
# Create columns 1..2, depending on source
|
||||
self._init_source_ui()
|
||||
# Create columns 3..6 (color, style, width, symbol)
|
||||
self._init_scan_index_ui()
|
||||
self._init_style_controls()
|
||||
|
||||
def _init_scan_index_ui(self):
|
||||
"""Create the Scan # editable combobox in column 3."""
|
||||
if self.source not in ("device", "history"):
|
||||
return
|
||||
self.scan_index_combo = QComboBox()
|
||||
self.scan_index_combo.setEditable(True)
|
||||
# Populate 'live' and all available history scan indices
|
||||
self.scan_index_combo.addItem("live", None)
|
||||
|
||||
scan_number_list = []
|
||||
scan_id_list = []
|
||||
try:
|
||||
history = getattr(self.curve_tree.client, "history", None)
|
||||
if history is not None:
|
||||
scan_number_list = getattr(history, "_scan_numbers", []) or []
|
||||
scan_id_list = getattr(history, "_scan_ids", []) or []
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot fetch scan numbers from BEC client: {e}")
|
||||
# If scan numbers cannot be fetched, only provide 'live' option
|
||||
scan_number_list = []
|
||||
scan_id_list = []
|
||||
|
||||
# Restrict input to 'live' or valid scan numbers
|
||||
allowed = set()
|
||||
try:
|
||||
allowed = set(int(n) for n in scan_number_list if isinstance(n, (int, str)))
|
||||
except Exception:
|
||||
allowed = set()
|
||||
validator = ScanIndexValidator(allowed, self.scan_index_combo)
|
||||
self.scan_index_combo.lineEdit().setValidator(validator)
|
||||
|
||||
# Add items: show scan numbers, store scan IDs as item data
|
||||
if scan_number_list and scan_id_list and len(scan_number_list) == len(scan_id_list):
|
||||
for num, sid in zip(scan_number_list, scan_id_list):
|
||||
self.scan_index_combo.addItem(str(num), sid)
|
||||
else:
|
||||
logger.error("Scan number and ID lists are mismatched or empty.")
|
||||
|
||||
# Select current based on existing config
|
||||
selected = False
|
||||
if getattr(self.config, "scan_id", None): # scan_id matching only
|
||||
for i in range(self.scan_index_combo.count()):
|
||||
if self.scan_index_combo.itemData(i) == self.config.scan_id:
|
||||
self.scan_index_combo.setCurrentIndex(i)
|
||||
selected = True
|
||||
break
|
||||
if not selected:
|
||||
self.scan_index_combo.setCurrentText("live")
|
||||
|
||||
self.tree.setItemWidget(self, 3, self.scan_index_combo)
|
||||
|
||||
def _init_actions(self):
|
||||
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
|
||||
self.actions_widget = QWidget()
|
||||
@@ -114,7 +193,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
actions_layout.addWidget(self.delete_button)
|
||||
|
||||
# If device row, add "Add DAP" button
|
||||
if self.source == "device":
|
||||
if self.source in ("device", "history"):
|
||||
self.add_dap_button = QPushButton("DAP")
|
||||
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
||||
actions_layout.addWidget(self.add_dap_button)
|
||||
@@ -123,7 +202,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
|
||||
def _init_source_ui(self):
|
||||
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
|
||||
if self.source == "device":
|
||||
if self.source in ("device", "history"):
|
||||
# Device row: columns 1..2 are device line edits
|
||||
self.device_edit = DeviceComboBox(parent=self.tree)
|
||||
self.device_edit.insertItem(0, "")
|
||||
@@ -152,7 +231,6 @@ class CurveRow(QTreeWidgetItem):
|
||||
|
||||
self.tree.setItemWidget(self, 1, self.device_edit)
|
||||
self.tree.setItemWidget(self, 2, self.entry_edit)
|
||||
|
||||
else:
|
||||
# DAP row: column1= "Model" label, column2= DapComboBox
|
||||
self.label_widget = QLabel("Model")
|
||||
@@ -171,31 +249,31 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.tree.setItemWidget(self, 2, self.dap_combo)
|
||||
|
||||
def _init_style_controls(self):
|
||||
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 3
|
||||
"""Create columns 4..7: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 4
|
||||
self.color_button = ColorButtonNative(color=self.config.color)
|
||||
self.color_button.color_changed.connect(self._on_color_changed)
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
self.tree.setItemWidget(self, 4, self.color_button)
|
||||
|
||||
# Style in col 4
|
||||
# Style in col 5
|
||||
self.style_combo = QComboBox()
|
||||
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
|
||||
idx = self.style_combo.findText(self.config.pen_style)
|
||||
if idx >= 0:
|
||||
self.style_combo.setCurrentIndex(idx)
|
||||
self.tree.setItemWidget(self, 4, self.style_combo)
|
||||
self.tree.setItemWidget(self, 5, self.style_combo)
|
||||
|
||||
# Pen width in col 5
|
||||
# Pen width in col 6
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setRange(1, 20)
|
||||
self.width_spin.setValue(self.config.pen_width)
|
||||
self.tree.setItemWidget(self, 5, self.width_spin)
|
||||
self.tree.setItemWidget(self, 6, self.width_spin)
|
||||
|
||||
# Symbol size in col 6
|
||||
# Symbol size in col 7
|
||||
self.symbol_spin = QSpinBox()
|
||||
self.symbol_spin.setRange(1, 20)
|
||||
self.symbol_spin.setValue(self.config.symbol_size)
|
||||
self.tree.setItemWidget(self, 6, self.symbol_spin)
|
||||
self.tree.setItemWidget(self, 7, self.symbol_spin)
|
||||
|
||||
@SafeSlot(str, verify_sender=True)
|
||||
def _on_color_changed(self, new_color: str):
|
||||
@@ -209,8 +287,8 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.config.symbol_color = new_color
|
||||
|
||||
def add_dap_row(self):
|
||||
"""Create a new DAP row as a child. Only valid if source='device'."""
|
||||
if self.source != "device":
|
||||
"""Create a new DAP row as a child. Only valid if source is 'device' or 'history'."""
|
||||
if self.source not in ("device", "history"):
|
||||
return
|
||||
curve_tree = self.tree.parent()
|
||||
parent_label = self.config.label
|
||||
@@ -288,7 +366,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
Returns:
|
||||
dict: The serialized config based on the GUI state.
|
||||
"""
|
||||
if self.source == "device":
|
||||
if self.source in ("device", "history"):
|
||||
# Gather device name/entry
|
||||
device_name = ""
|
||||
device_entry = ""
|
||||
@@ -309,8 +387,23 @@ class CurveRow(QTreeWidgetItem):
|
||||
)
|
||||
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
|
||||
self.config.source = "device"
|
||||
self.config.label = f"{device_name}-{device_entry}"
|
||||
scan_combo_text = self.scan_index_combo.currentText()
|
||||
if scan_combo_text == "live" or scan_combo_text == "":
|
||||
self.config.scan_number = None
|
||||
self.config.scan_id = None
|
||||
self.config.source = "device"
|
||||
self.config.label = f"{device_name}-{device_entry}"
|
||||
if scan_combo_text.isdigit():
|
||||
try:
|
||||
scan_num = int(scan_combo_text)
|
||||
except ValueError:
|
||||
scan_num = None
|
||||
self.config.scan_number = scan_num
|
||||
self.config.scan_id = self.scan_index_combo.currentData()
|
||||
self.config.source = "history"
|
||||
# Label history curves with scan number suffix
|
||||
if scan_num is not None:
|
||||
self.config.label = f"{device_name}-{device_entry}-scan-{scan_num}"
|
||||
else:
|
||||
# DAP logic
|
||||
parent_conf_dict = {}
|
||||
@@ -443,10 +536,12 @@ class CurveTree(BECWidget, QWidget):
|
||||
self.toolbar.show_bundles(["curve_tree"])
|
||||
|
||||
def _init_tree(self):
|
||||
"""Initialize the QTreeWidget with 7 columns and compact widths."""
|
||||
"""Initialize the QTreeWidget with 8 columns and compact widths."""
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setColumnCount(7)
|
||||
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
|
||||
self.tree.setColumnCount(8)
|
||||
self.tree.setHeaderLabels(
|
||||
["Actions", "Name", "Entry", "Scan #", "Color", "Style", "Width", "Symbol"]
|
||||
)
|
||||
|
||||
header = self.tree.header()
|
||||
for idx in range(self.tree.columnCount()):
|
||||
@@ -456,10 +551,10 @@ class CurveTree(BECWidget, QWidget):
|
||||
header.setSectionResizeMode(idx, QHeaderView.Fixed)
|
||||
header.setStretchLastSection(False)
|
||||
self.tree.setColumnWidth(0, 90)
|
||||
self.tree.setColumnWidth(3, 70)
|
||||
self.tree.setColumnWidth(4, 80)
|
||||
self.tree.setColumnWidth(5, 50)
|
||||
self.tree.setColumnWidth(4, 70)
|
||||
self.tree.setColumnWidth(5, 80)
|
||||
self.tree.setColumnWidth(6, 50)
|
||||
self.tree.setColumnWidth(7, 50)
|
||||
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
@@ -583,9 +678,9 @@ class CurveTree(BECWidget, QWidget):
|
||||
self.tree.clear()
|
||||
self.all_items = []
|
||||
|
||||
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
|
||||
top_curves = [c for c in self.waveform.curves if c.config.source in ("device", "history")]
|
||||
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
|
||||
for dev in device_curves:
|
||||
for dev in top_curves:
|
||||
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
|
||||
for dap in dap_curves:
|
||||
if dap.config.parent_label == dev.config.label:
|
||||
|
||||
@@ -8,6 +8,7 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.scan_data_container import ScanDataContainer
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
@@ -35,6 +36,9 @@ from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
|
||||
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -63,49 +67,8 @@ class Waveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "show_chart"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
*PlotBase.USER_ACCESS,
|
||||
"_config_dict",
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"auto_range",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
@@ -163,6 +126,7 @@ class Waveform(PlotBase):
|
||||
# Curve data
|
||||
self._sync_curves = []
|
||||
self._async_curves = []
|
||||
self._history_curves = []
|
||||
self._slice_index = None
|
||||
self._dap_curves = []
|
||||
self._mode = None
|
||||
@@ -179,12 +143,14 @@ class Waveform(PlotBase):
|
||||
"readout_priority": None,
|
||||
"label_suffix": "",
|
||||
}
|
||||
self._current_x_device: tuple[str, str] | None = None
|
||||
|
||||
# Specific GUI elements
|
||||
self._init_roi_manager()
|
||||
self.dap_summary = None
|
||||
self.dap_summary_dialog = None
|
||||
self._add_fit_parameters_popup()
|
||||
self.scan_history_dialog = None
|
||||
self._add_waveform_specific_popup()
|
||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
@@ -252,7 +218,7 @@ class Waveform(PlotBase):
|
||||
super().add_side_menus()
|
||||
self._add_dap_summary_side_menu()
|
||||
|
||||
def _add_fit_parameters_popup(self):
|
||||
def _add_waveform_specific_popup(self):
|
||||
"""
|
||||
Add popups to the Waveform widget.
|
||||
"""
|
||||
@@ -262,11 +228,24 @@ class Waveform(PlotBase):
|
||||
icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"scan_history",
|
||||
MaterialIconAction(
|
||||
icon_name="manage_search",
|
||||
tooltip="Open Scan History browser",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
),
|
||||
)
|
||||
self.toolbar.get_bundle("axis_popup").add_action("fit_params")
|
||||
self.toolbar.get_bundle("axis_popup").add_action("scan_history")
|
||||
|
||||
self.toolbar.components.get_action("fit_params").action.triggered.connect(
|
||||
self.show_dap_summary_popup
|
||||
)
|
||||
self.toolbar.components.get_action("scan_history").action.triggered.connect(
|
||||
self.show_scan_history_popup
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _reset_view(self):
|
||||
@@ -414,6 +393,47 @@ class Waveform(PlotBase):
|
||||
self.toolbar.components.get_action("roi_linear").action.setChecked(False)
|
||||
self._roi_manager.toggle_roi(False)
|
||||
|
||||
################################################################################
|
||||
# Scan History browser popup
|
||||
# TODO this is so far quick implementation just as popup, we should make scan history also standalone widget later
|
||||
def show_scan_history_popup(self):
|
||||
"""
|
||||
Show the scan history popup.
|
||||
"""
|
||||
scan_history_action = self.toolbar.components.get_action("scan_history").action
|
||||
if self.scan_history_dialog is None or not self.scan_history_dialog.isVisible():
|
||||
self.scan_history_widget = ScanHistoryBrowser(parent=self)
|
||||
self.scan_history_dialog = QDialog(modal=False)
|
||||
self.scan_history_dialog.setWindowTitle(f"{self.object_name} - Scan History Browser")
|
||||
self.scan_history_dialog.layout = QVBoxLayout(self.scan_history_dialog)
|
||||
self.scan_history_dialog.layout.addWidget(self.scan_history_widget)
|
||||
self.scan_history_widget.scan_history_device_viewer.request_history_plot.connect(
|
||||
lambda scan_id, device_name, signal_name: self.plot(
|
||||
y_name=device_name, y_entry=signal_name, scan_id=scan_id
|
||||
)
|
||||
)
|
||||
self.scan_history_dialog.finished.connect(self._scan_history_closed)
|
||||
self.scan_history_dialog.show()
|
||||
self.scan_history_dialog.resize(780, 320)
|
||||
scan_history_action.setChecked(True)
|
||||
else:
|
||||
# If already open, bring it to the front
|
||||
self.scan_history_dialog.raise_()
|
||||
self.scan_history_dialog.activateWindow()
|
||||
scan_history_action.setChecked(True) # keep it toggle
|
||||
|
||||
def _scan_history_closed(self):
|
||||
"""
|
||||
Slot for when the scan history dialog is closed.
|
||||
"""
|
||||
if self.scan_history_dialog is None:
|
||||
return
|
||||
self.scan_history_widget.close()
|
||||
self.scan_history_widget.deleteLater()
|
||||
self.scan_history_dialog.deleteLater()
|
||||
self.scan_history_dialog = None
|
||||
self.toolbar.components.get_action("scan_history").action.setChecked(False)
|
||||
|
||||
################################################################################
|
||||
# Dap Summary
|
||||
|
||||
@@ -503,7 +523,11 @@ class Waveform(PlotBase):
|
||||
self.x_axis_mode["name"] = value
|
||||
if value not in ["timestamp", "index", "auto"]:
|
||||
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(value, None)
|
||||
self._current_x_device = (value, self.x_axis_mode["entry"])
|
||||
self._switch_x_axis_item(mode=value)
|
||||
self._current_x_device = None
|
||||
self._refresh_history_curves()
|
||||
self._update_curve_visibility()
|
||||
self.async_signal_update.emit()
|
||||
self.sync_signal_update.emit()
|
||||
self.plot_item.enableAutoRange(x=True)
|
||||
@@ -531,6 +555,8 @@ class Waveform(PlotBase):
|
||||
return
|
||||
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(self.x_mode, value)
|
||||
self._switch_x_axis_item(mode="device")
|
||||
self._refresh_history_curves()
|
||||
self._update_curve_visibility()
|
||||
self.async_signal_update.emit()
|
||||
self.sync_signal_update.emit()
|
||||
self.plot_item.enableAutoRange(x=True)
|
||||
@@ -671,6 +697,8 @@ class Waveform(PlotBase):
|
||||
color: str | None = None,
|
||||
label: str | None = None,
|
||||
dap: str | None = None,
|
||||
scan_id: str | None = None,
|
||||
scan_number: int | None = None,
|
||||
**kwargs,
|
||||
) -> Curve:
|
||||
"""
|
||||
@@ -690,9 +718,13 @@ class Waveform(PlotBase):
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
|
||||
|
||||
Returns:
|
||||
Curve: The curve object.
|
||||
@@ -762,6 +794,8 @@ class Waveform(PlotBase):
|
||||
label=label,
|
||||
color=color,
|
||||
source=source,
|
||||
scan_id=scan_id,
|
||||
scan_number=scan_number,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -769,10 +803,13 @@ class Waveform(PlotBase):
|
||||
if source == "device":
|
||||
config.signal = DeviceSignal(name=y_name, entry=y_entry)
|
||||
|
||||
if scan_id is not None or scan_number is not None:
|
||||
config.source = "history"
|
||||
|
||||
# CREATE THE CURVE
|
||||
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
|
||||
|
||||
if dap is not None and source == "device":
|
||||
if dap is not None and curve.config.source in ("device", "history", "custom"):
|
||||
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
|
||||
|
||||
return curve
|
||||
@@ -789,11 +826,12 @@ class Waveform(PlotBase):
|
||||
**kwargs,
|
||||
) -> Curve:
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
Create a new DAP curve referencing the existing curve `device_label`, with the
|
||||
data processing model `dap_name`. DAP curves can be attached to curves that
|
||||
originate from live devices, history, or fully custom data sources.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
@@ -803,20 +841,25 @@ class Waveform(PlotBase):
|
||||
Curve: The new DAP curve.
|
||||
"""
|
||||
|
||||
# 1) Find the existing device curve by label
|
||||
# 1) Find the existing curve by label
|
||||
device_curve = self._find_curve_by_label(device_label)
|
||||
if not device_curve:
|
||||
raise ValueError(f"No existing curve found with label '{device_label}'.")
|
||||
if device_curve.config.source != "device":
|
||||
if device_curve.config.source not in ("device", "history", "custom"):
|
||||
raise ValueError(
|
||||
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
|
||||
f"Curve '{device_label}' is not compatible with DAP. "
|
||||
f"Only device, history, or custom curves support fitting."
|
||||
)
|
||||
|
||||
dev_name = device_curve.config.signal.name
|
||||
dev_entry = device_curve.config.signal.entry
|
||||
dev_name = getattr(getattr(device_curve.config, "signal", None), "name", None)
|
||||
dev_entry = getattr(getattr(device_curve.config, "signal", None), "entry", None)
|
||||
if dev_name is None:
|
||||
dev_name = device_label
|
||||
if dev_entry is None:
|
||||
dev_entry = "custom"
|
||||
|
||||
# 2) Build a label for the new DAP curve
|
||||
dap_label = f"{dev_name}-{dev_entry}-{dap_name}"
|
||||
dap_label = f"{device_label}-{dap_name}"
|
||||
|
||||
# 3) Possibly raise if the DAP curve already exists
|
||||
if self._check_curve_id(dap_label):
|
||||
@@ -869,7 +912,23 @@ class Waveform(PlotBase):
|
||||
ValueError: If a duplicate curve label/config is found, or if
|
||||
custom data is missing for `source='custom'`.
|
||||
"""
|
||||
scan_item: ScanDataContainer | None = None
|
||||
if config.source == "history":
|
||||
scan_item = self.get_history_scan_item(
|
||||
scan_id=config.scan_id, scan_index=config.scan_number
|
||||
)
|
||||
if scan_item is None:
|
||||
raise ValueError(
|
||||
f"Could not find scan item for history curve '{config.label}' with scan_id='{config.scan_id}' and scan_number='{config.scan_number}'."
|
||||
)
|
||||
|
||||
config.scan_id = scan_item.metadata["bec"]["scan_id"]
|
||||
config.scan_number = scan_item.metadata["bec"]["scan_number"]
|
||||
|
||||
label = config.label
|
||||
if config.source == "history":
|
||||
label = f"{config.signal.name}-{config.signal.entry}-scan-{config.scan_number}"
|
||||
config.label = label
|
||||
if not label:
|
||||
# Fallback label
|
||||
label = WidgetContainerUtils.generate_unique_name(
|
||||
@@ -891,7 +950,7 @@ class Waveform(PlotBase):
|
||||
raise ValueError("For 'custom' curves, x_data and y_data must be provided.")
|
||||
|
||||
# Actually create the Curve item
|
||||
curve = self._add_curve_object(name=label, config=config)
|
||||
curve = self._add_curve_object(name=label, config=config, scan_item=scan_item)
|
||||
|
||||
# If custom => set initial data
|
||||
if config.source == "custom" and x_data is not None and y_data is not None:
|
||||
@@ -908,6 +967,8 @@ class Waveform(PlotBase):
|
||||
self.setup_dap_for_scan()
|
||||
self.roi_enable.emit(True) # Enable the ROI toolbar action
|
||||
self.request_dap() # Request DAP update directly without blocking proxy
|
||||
if config.source == "history":
|
||||
self._history_curves.append(curve)
|
||||
|
||||
QTimer.singleShot(
|
||||
150, self.auto_range
|
||||
@@ -915,24 +976,175 @@ class Waveform(PlotBase):
|
||||
|
||||
return curve
|
||||
|
||||
def _add_curve_object(self, name: str, config: CurveConfig) -> Curve:
|
||||
def _add_curve_object(
|
||||
self, name: str, config: CurveConfig, scan_item: ScanDataContainer | None = None
|
||||
) -> Curve | None:
|
||||
"""
|
||||
Low-level creation of the PlotDataItem (Curve) from a `CurveConfig`.
|
||||
|
||||
Args:
|
||||
name (str): The name/label of the curve.
|
||||
config (CurveConfig): Configuration model describing the curve.
|
||||
scan_item (ScanDataContainer | None): Optional scan item for history curves.
|
||||
|
||||
Returns:
|
||||
Curve: The newly created curve object, added to the plot.
|
||||
"""
|
||||
curve = Curve(config=config, name=name, parent_item=self)
|
||||
self.plot_item.addItem(curve)
|
||||
if scan_item is not None:
|
||||
self._fetch_history_data_for_curve(curve, scan_item)
|
||||
self._categorise_device_curves()
|
||||
curve.visibleChanged.connect(self._refresh_crosshair_markers)
|
||||
curve.visibleChanged.connect(self.auto_range)
|
||||
return curve
|
||||
|
||||
def _fetch_history_data_for_curve(
|
||||
self, curve: Curve, scan_item: ScanDataContainer
|
||||
) -> Curve | None:
|
||||
# Check if the data are already set
|
||||
device = curve.config.signal.name
|
||||
entry = curve.config.signal.entry
|
||||
|
||||
all_devices_used = getattr(
|
||||
getattr(scan_item, "_msg", None), "stored_data_info", None
|
||||
) or getattr(scan_item, "stored_data_info", None)
|
||||
if all_devices_used is None:
|
||||
curve.remove()
|
||||
raise ValueError(
|
||||
f"No stored data info found in scan item ID:{curve.config.scan_id} for curve '{curve.name()}'. "
|
||||
f"Upgrade BEC to the latest version."
|
||||
)
|
||||
|
||||
# 1. get y data
|
||||
x_data, y_data = None, None
|
||||
if device not in all_devices_used:
|
||||
raise ValueError(f"Device '{device}' not found in scan item ID:{curve.config.scan_id}.")
|
||||
if entry not in all_devices_used[device]:
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{device}' in scan item ID:{curve.config.scan_id}."
|
||||
)
|
||||
y_shape = all_devices_used.get(device).get(entry).shape[0]
|
||||
|
||||
# Determine X-axis data
|
||||
if self.x_axis_mode["name"] == "index":
|
||||
x_data = np.arange(y_shape)
|
||||
curve.config.current_x_mode = "index"
|
||||
self._update_x_label_suffix(" (index)")
|
||||
elif self.x_axis_mode["name"] == "timestamp":
|
||||
y_device = scan_item.devices.get(device)
|
||||
x_data = y_device.get(entry).read().get("timestamp")
|
||||
curve.config.current_x_mode = "timestamp"
|
||||
self._update_x_label_suffix(" (timestamp)")
|
||||
elif self.x_axis_mode["name"] not in ("index", "timestamp", "auto"): # Custom device mode
|
||||
if self.x_axis_mode["name"] not in all_devices_used:
|
||||
logger.warning(
|
||||
f"Custom device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
x_entry_custom = self.x_axis_mode.get("entry")
|
||||
if x_entry_custom is None:
|
||||
x_entry_custom = self.entry_validator.validate_signal(
|
||||
self.x_axis_mode["name"], None
|
||||
)
|
||||
if x_entry_custom not in all_devices_used[self.x_axis_mode["name"]]:
|
||||
logger.warning(
|
||||
f"Custom entry '{x_entry_custom}' for device '{self.x_axis_mode['name']}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
x_shape = (
|
||||
scan_item._msg.stored_data_info.get(self.x_axis_mode["name"])
|
||||
.get(x_entry_custom)
|
||||
.shape[0]
|
||||
)
|
||||
if x_shape != y_shape:
|
||||
logger.warning(
|
||||
f"Shape mismatch for x data '{x_shape}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
x_device = scan_item.devices.get(self.x_axis_mode["name"])
|
||||
x_data = x_device.get(x_entry_custom).read().get("value")
|
||||
curve.config.current_x_mode = self.x_axis_mode["name"]
|
||||
self._update_x_label_suffix(f" (custom: {self.x_axis_mode['name']}-{x_entry_custom})")
|
||||
elif self.x_axis_mode["name"] == "auto":
|
||||
if (
|
||||
self._current_x_device is None
|
||||
): # Scenario where no x device is set yet, because there was no live scan done in this widget yet
|
||||
# If no current x device, use the first motor from scan item
|
||||
scan_motors = self._ensure_str_list(
|
||||
scan_item.metadata.get("bec").get("scan_report_devices")
|
||||
)
|
||||
if not scan_motors: # scan was done without reported motor from whatever reason
|
||||
x_data = np.arange(y_shape) # Fallback to index
|
||||
y_data = scan_item.devices.get(device).get(entry).read().get("value")
|
||||
curve.set_data(x=x_data, y=y_data)
|
||||
self._update_x_label_suffix(" (auto: index)")
|
||||
return curve
|
||||
x_entry = self.entry_validator.validate_signal(scan_motors[0], None)
|
||||
if x_entry not in all_devices_used.get(scan_motors[0], {}):
|
||||
logger.warning(
|
||||
f"Auto x entry '{x_entry}' for device '{scan_motors[0]}' not found in scan item of history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
if y_shape != all_devices_used.get(scan_motors[0]).get(x_entry, {}).shape[0]:
|
||||
logger.warning(
|
||||
f"Shape mismatch for x data '{all_devices_used.get(scan_motors[0]).get(x_entry, {}).get('shape', [0])[0]}' and y data '{y_shape}' in history curve '{curve.name()}'; scan ID: {curve.config.scan_id}."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
x_data = scan_item.devices.get(scan_motors[0]).get(x_entry).read().get("value")
|
||||
self._current_x_device = (scan_motors[0], x_entry)
|
||||
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
|
||||
curve.config.current_x_mode = "auto"
|
||||
self._update_x_label_suffix(f" (auto: {scan_motors[0]}-{x_entry})")
|
||||
else: # Scan in auto mode was done and live scan already set the current x device
|
||||
if self._current_x_device[0] not in all_devices_used:
|
||||
logger.warning(
|
||||
f"Auto x data for device '{self._current_x_device[0]}' "
|
||||
f"and entry '{self._current_x_device[1]}'"
|
||||
f" not found in scan item of the history curve {curve.name()}."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
x_device = scan_item.devices.get(self._current_x_device[0])
|
||||
x_data = x_device.get(self._current_x_device[1]).read().get("value")
|
||||
curve.config.current_x_mode = "auto"
|
||||
self._update_x_label_suffix(
|
||||
f" (auto: {self._current_x_device[0]}-{self._current_x_device[1]})"
|
||||
)
|
||||
if x_data is None:
|
||||
logger.warning(
|
||||
f"X data for curve '{curve.name()}' could not be determined. "
|
||||
f"Check if the x_mode '{self.x_axis_mode['name']}' is valid for the scan item."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
if y_data is None:
|
||||
y_data = scan_item.devices.get(device).get(entry).read().get("value")
|
||||
if y_data is None:
|
||||
logger.warning(
|
||||
f"Y data for curve '{curve.name()}' could not be determined. "
|
||||
f"Check if the device '{device}' and entry '{entry}' are valid for the scan item."
|
||||
)
|
||||
curve.setVisible(False)
|
||||
return
|
||||
curve.set_data(x=x_data, y=y_data)
|
||||
return curve
|
||||
|
||||
def _refresh_history_curves(self):
|
||||
for curve in self._history_curves:
|
||||
scan_item = self.get_history_scan_item(
|
||||
scan_id=curve.config.scan_id, scan_index=curve.config.scan_number
|
||||
)
|
||||
if scan_item is not None:
|
||||
self._fetch_history_data_for_curve(curve, scan_item)
|
||||
else:
|
||||
logger.warning(f"Scan item for curve {curve.name()} not found.")
|
||||
|
||||
def _refresh_crosshair_markers(self):
|
||||
"""
|
||||
Refresh the crosshair markers when a curve visibility changes.
|
||||
@@ -967,7 +1179,42 @@ class Waveform(PlotBase):
|
||||
Clear all data from the plot widget, but keep the curve references.
|
||||
"""
|
||||
for c in self.curves:
|
||||
c.clear_data()
|
||||
if c.config.source != "history":
|
||||
c.clear_data()
|
||||
|
||||
# X-axis compatibility helpers
|
||||
def _is_curve_compatible(self, curve: Curve) -> bool:
|
||||
"""
|
||||
Return True when *curve* can be shown with the current x-axis mode.
|
||||
|
||||
- ‘index’, ‘timestamp’ are always compatible.
|
||||
- For history curves we check whether the requested motor
|
||||
(self.x_axis_mode["name"]) exists in the cached
|
||||
history_data_buffer["x"] dictionary.
|
||||
- DAP is done by checking if the parent curve is visible.
|
||||
- Device curves are fetched by update sync/async curves, which solves the compatibility there.
|
||||
"""
|
||||
mode = self.x_axis_mode.get("name", "index")
|
||||
if mode in ("index", "timestamp"): # always compatible - wild west mode
|
||||
return True
|
||||
if curve.config.source == "history":
|
||||
scan_item = self.get_history_scan_item(
|
||||
scan_id=curve.config.scan_id, scan_index=curve.config.scan_number
|
||||
)
|
||||
curve = self._fetch_history_data_for_curve(curve, scan_item)
|
||||
if curve is None:
|
||||
return False
|
||||
if curve.config.source == "dap":
|
||||
parent_curve = self._find_curve_by_label(curve.config.parent_label)
|
||||
if parent_curve.isVisible():
|
||||
return True
|
||||
return False # DAP curve is not compatible if parent curve is not visible
|
||||
return True
|
||||
|
||||
def _update_curve_visibility(self) -> None:
|
||||
"""Show or hide curves according to `_is_curve_compatible`."""
|
||||
for c in self.curves:
|
||||
c.setVisible(self._is_curve_compatible(c))
|
||||
|
||||
def clear_all(self):
|
||||
"""
|
||||
@@ -1130,7 +1377,7 @@ class Waveform(PlotBase):
|
||||
self.scan_id = current_scan_id
|
||||
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan
|
||||
self._slice_index = None # Reset the slice index
|
||||
|
||||
self._update_curve_visibility()
|
||||
self._mode = self._categorise_device_curves()
|
||||
|
||||
# First trigger to sync and async data
|
||||
@@ -1208,7 +1455,7 @@ class Waveform(PlotBase):
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
x_data = self._get_x_data(device_name, device_entry)
|
||||
if x_data is not None:
|
||||
if len(x_data) == 1:
|
||||
if np.isscalar(x_data):
|
||||
self.clear_data()
|
||||
return
|
||||
if device_data is not None and x_data is not None:
|
||||
@@ -1381,6 +1628,9 @@ class Waveform(PlotBase):
|
||||
continue
|
||||
# Ensure we have numpy array for data_plot_y
|
||||
data_plot_y = np.asarray(data_plot_y)
|
||||
if data_plot_y.ndim == 0:
|
||||
# Convert scalars/0d arrays to 1d so len() and stacking work
|
||||
data_plot_y = data_plot_y.reshape(1)
|
||||
# Add
|
||||
if instruction == "add":
|
||||
if len(max_shape) > 1:
|
||||
@@ -1616,6 +1866,7 @@ class Waveform(PlotBase):
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else [0]
|
||||
new_suffix = f" (custom: {x_name}-{x_entry})"
|
||||
self._current_x_device = (x_name, x_entry)
|
||||
|
||||
# 2 User wants timestamp
|
||||
if self.x_axis_mode["name"] == "timestamp":
|
||||
@@ -1630,11 +1881,13 @@ class Waveform(PlotBase):
|
||||
timestamps = entry_obj.read()["timestamp"] if entry_obj else [0]
|
||||
x_data = timestamps
|
||||
new_suffix = " (timestamp)"
|
||||
self._current_x_device = None
|
||||
|
||||
# 3 User wants index
|
||||
if self.x_axis_mode["name"] == "index":
|
||||
x_data = None
|
||||
new_suffix = " (index)"
|
||||
self._current_x_device = None
|
||||
|
||||
# 4 Best effort automatic mode
|
||||
if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
|
||||
@@ -1642,6 +1895,7 @@ class Waveform(PlotBase):
|
||||
if len(self._async_curves) > 0:
|
||||
x_data = None
|
||||
new_suffix = " (auto: index)"
|
||||
self._current_x_device = None
|
||||
# 4.2 If there are sync curves, use the first device from the scan report
|
||||
else:
|
||||
try:
|
||||
@@ -1664,6 +1918,7 @@ class Waveform(PlotBase):
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||
new_suffix = f" (auto: {x_name}-{x_entry})"
|
||||
self._current_x_device = (x_name, x_entry)
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
return x_data
|
||||
|
||||
@@ -1766,49 +2021,83 @@ class Waveform(PlotBase):
|
||||
logger.info(f"Scan {self.scan_id} => mode={self._mode}")
|
||||
return mode
|
||||
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
|
||||
def get_history_scan_item(
|
||||
self, scan_index: int = None, scan_id: str = None
|
||||
) -> ScanDataContainer | None:
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
Provide only one of scan_id or scan_index.
|
||||
Get scan item from history based on scan_id or scan_index.
|
||||
If both are provided, scan_id takes precedence and the resolved scan_number
|
||||
will be read from the fetched item.
|
||||
|
||||
Args:
|
||||
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
|
||||
scan_id (str, optional): ScanID of the scan to fetch. Defaults to None.
|
||||
scan_index (int, optional): Index (scan number) of the scan to fetch. Defaults to None.
|
||||
|
||||
Returns:
|
||||
ScanDataContainer | None: The fetched scan item or None if no item was found.
|
||||
"""
|
||||
if scan_index is not None and scan_id is not None:
|
||||
raise ValueError("Only one of scan_id or scan_index can be provided.")
|
||||
scan_index = None # Prefer scan_id when both are given
|
||||
|
||||
if scan_index is None and scan_id is None:
|
||||
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
|
||||
logger.warning("Neither scan_id or scan_number was provided, fetching the latest scan")
|
||||
scan_index = -1
|
||||
|
||||
if scan_index is None:
|
||||
self.scan_id = scan_id
|
||||
self.scan_item = self.client.history.get_by_scan_id(scan_id)
|
||||
self._emit_signal_update()
|
||||
return
|
||||
return self.client.history.get_by_scan_id(scan_id)
|
||||
|
||||
if scan_index == -1:
|
||||
scan_item = self.client.queue.scan_storage.current_scan
|
||||
if scan_item is not None:
|
||||
if scan_item.status_message is None:
|
||||
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
|
||||
return
|
||||
self.scan_item = scan_item
|
||||
self.scan_id = scan_item.scan_id
|
||||
self._emit_signal_update()
|
||||
return
|
||||
return None
|
||||
return scan_item
|
||||
|
||||
if len(self.client.history) == 0:
|
||||
logger.info("No scans executed so far. Skipping scan history update.")
|
||||
logger.info("No scans executed so far. Cannot fetch scan history.")
|
||||
return None
|
||||
|
||||
# check if scan_index is negative, then fetch it just from the list from the end
|
||||
if int(scan_index) < 0:
|
||||
return self.client.history[scan_index]
|
||||
scan_item = self.client.history.get_by_scan_number(scan_index)
|
||||
if scan_item is None:
|
||||
logger.warning(f"Scan with scan_number {scan_index} not found in history.")
|
||||
return None
|
||||
if isinstance(scan_item, list):
|
||||
if len(scan_item) > 1:
|
||||
logger.warning(
|
||||
f"Multiple scans found with scan_number {scan_index}. Returning the latest one."
|
||||
)
|
||||
scan_item = scan_item[-1]
|
||||
return scan_item
|
||||
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
|
||||
"""
|
||||
Update the scan curves with the data from the scan storage.
|
||||
If both arguments are provided, scan_id takes precedence and scan_index is ignored.
|
||||
|
||||
Args:
|
||||
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
|
||||
scan_index(int, optional): Index (scan number) of the scan to be updated. Defaults to None.
|
||||
"""
|
||||
self.scan_item = self.get_history_scan_item(scan_index=scan_index, scan_id=scan_id)
|
||||
|
||||
if self.scan_item is None:
|
||||
return
|
||||
|
||||
self.scan_item = self.client.history[scan_index]
|
||||
metadata = self.scan_item.metadata
|
||||
self.scan_id = metadata["bec"]["scan_id"]
|
||||
if scan_id is not None:
|
||||
self.scan_id = scan_id
|
||||
else:
|
||||
# If scan_number was used, set the scan_id from the fetched item
|
||||
if hasattr(self.scan_item, "metadata"):
|
||||
self.scan_id = self.scan_item.metadata["bec"]["scan_id"]
|
||||
else:
|
||||
self.scan_id = self.scan_item.scan_id
|
||||
|
||||
self._emit_signal_update()
|
||||
|
||||
@@ -2039,6 +2328,9 @@ class Waveform(PlotBase):
|
||||
if self.dap_summary_dialog is not None:
|
||||
self.dap_summary_dialog.reject()
|
||||
self.dap_summary_dialog = None
|
||||
if self.scan_history_dialog is not None:
|
||||
self.scan_history_dialog.reject()
|
||||
self.scan_history_dialog = None
|
||||
super().cleanup()
|
||||
|
||||
|
||||
@@ -2046,7 +2338,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Waveform Demo")
|
||||
self.resize(800, 600)
|
||||
self.resize(1200, 600)
|
||||
self.main_widget = QWidget(self)
|
||||
self.layout = QHBoxLayout(self.main_widget)
|
||||
self.setCentralWidget(self.main_widget)
|
||||
@@ -2058,8 +2350,31 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.waveform_side.plot(y_name="bpm3a", y_entry="bpm3a")
|
||||
|
||||
self.custom_waveform = Waveform(popups=True)
|
||||
self._populate_custom_curve_demo()
|
||||
|
||||
self.layout.addWidget(self.waveform_side)
|
||||
self.layout.addWidget(self.waveform_popup)
|
||||
self.layout.addWidget(self.custom_waveform)
|
||||
|
||||
def _populate_custom_curve_demo(self):
|
||||
"""
|
||||
Showcase how to attach a DAP fit to a fully custom curve.
|
||||
|
||||
The example generates a noisy Gaussian trace, plots it as custom data, and
|
||||
immediately adds a Gaussian model fit. When the widget is plugged into a
|
||||
running BEC instance, the fit curve will be requested like any other device
|
||||
signal. This keeps the example minimal while demonstrating the new workflow.
|
||||
"""
|
||||
x = np.linspace(-4, 4, 600)
|
||||
rng = np.random.default_rng(42)
|
||||
noise = rng.normal(loc=0, scale=0.05, size=x.size)
|
||||
amplitude = 3.5
|
||||
center = 0.5
|
||||
sigma = 0.8
|
||||
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise
|
||||
|
||||
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -12,8 +12,8 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class ProgressbarConnections(BaseModel):
|
||||
slot: Literal["on_scan_progress", "on_device_readback"] = None
|
||||
endpoint: EndpointInfo | str = None
|
||||
slot: Literal["on_scan_progress", "on_device_readback", None] = None
|
||||
endpoint: EndpointInfo | str | None = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("endpoint")
|
||||
@@ -222,9 +222,10 @@ class Ring(BECConnector, QObject):
|
||||
device(str): Device name for the device readback mode, only used when mode is "device"
|
||||
"""
|
||||
if mode == "manual":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
if self.config.connections.slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections.slot = None
|
||||
self.config.connections.endpoint = None
|
||||
elif mode == "scan":
|
||||
|
||||
@@ -22,13 +22,9 @@ class RingProgressBarConfig(ConnectionConfig):
|
||||
color_map: Optional[str] = Field(
|
||||
"plasma", description="Color scheme for the progress bars.", validate_default=True
|
||||
)
|
||||
min_number_of_bars: int | None = Field(
|
||||
1, description="Minimum number of progress bars to display."
|
||||
)
|
||||
max_number_of_bars: int | None = Field(
|
||||
10, description="Maximum number of progress bars to display."
|
||||
)
|
||||
num_bars: int | None = Field(1, description="Number of progress bars to display.")
|
||||
min_number_of_bars: int = Field(1, description="Minimum number of progress bars to display.")
|
||||
max_number_of_bars: int = Field(10, description="Maximum number of progress bars to display.")
|
||||
num_bars: int = Field(1, description="Number of progress bars to display.")
|
||||
gap: int | None = Field(20, description="Gap between progress bars.")
|
||||
auto_updates: bool | None = Field(
|
||||
True, description="Enable or disable updates based on scan queue status."
|
||||
@@ -242,7 +238,7 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
for i, ring in enumerate(self._rings):
|
||||
ring.config.index = i
|
||||
|
||||
def set_precision(self, precision: int, bar_index: int = None):
|
||||
def set_precision(self, precision: int, bar_index: int | None = None):
|
||||
"""
|
||||
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
|
||||
|
||||
@@ -271,9 +267,9 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
min_values(int|float | list[float]): Minimum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of minimum values for each progress bar.
|
||||
max_values(int|float | list[float]): Maximum value(s) for the progress bars. If multiple progress bars are displayed, provide a list of maximum values for each progress bar.
|
||||
"""
|
||||
if isinstance(min_values, int) or isinstance(min_values, float):
|
||||
if isinstance(min_values, (int, float)):
|
||||
min_values = [min_values]
|
||||
if isinstance(max_values, int) or isinstance(max_values, float):
|
||||
if isinstance(max_values, (int, float)):
|
||||
max_values = [max_values]
|
||||
min_values = self._adjust_list_to_bars(min_values)
|
||||
max_values = self._adjust_list_to_bars(max_values)
|
||||
@@ -441,14 +437,10 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
Returns:
|
||||
Ring: Ring object.
|
||||
"""
|
||||
found_ring = None
|
||||
for ring in self._rings:
|
||||
if ring.config.index == index:
|
||||
found_ring = ring
|
||||
break
|
||||
if found_ring is None:
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
return found_ring
|
||||
return ring
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
|
||||
def enable_auto_updates(self, enable: bool = True):
|
||||
"""
|
||||
@@ -485,29 +477,30 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
primary_queue = msg.get("queue").get("primary")
|
||||
info = primary_queue.get("info", None)
|
||||
|
||||
if info:
|
||||
active_request_block = info[0].get("active_request_block", None)
|
||||
if active_request_block:
|
||||
report_instructions = active_request_block.get("report_instructions", None)
|
||||
if report_instructions:
|
||||
instruction_type = list(report_instructions[0].keys())[0]
|
||||
if instruction_type == "scan_progress":
|
||||
self._hook_scan_progress(ring_index=0)
|
||||
elif instruction_type == "readback":
|
||||
devices = report_instructions[0].get("readback").get("devices")
|
||||
start = report_instructions[0].get("readback").get("start")
|
||||
end = report_instructions[0].get("readback").get("end")
|
||||
if self.config.num_bars != len(devices):
|
||||
self.set_number_of_bars(len(devices))
|
||||
for index, device in enumerate(devices):
|
||||
self._hook_readback(index, device, start[index], end[index])
|
||||
else:
|
||||
logger.error(f"{instruction_type} not supported yet.")
|
||||
if not info:
|
||||
return
|
||||
active_request_block = info[0].get("active_request_block", None)
|
||||
if not active_request_block:
|
||||
return
|
||||
report_instructions = active_request_block.get("report_instructions", None)
|
||||
if not report_instructions:
|
||||
return
|
||||
|
||||
# elif instruction_type == "device_progress":
|
||||
# print("hook device_progress")
|
||||
instruction_type = list(report_instructions[0].keys())[0]
|
||||
if instruction_type == "scan_progress":
|
||||
self._hook_scan_progress(ring_index=0)
|
||||
elif instruction_type == "readback":
|
||||
devices = report_instructions[0].get("readback").get("devices")
|
||||
start = report_instructions[0].get("readback").get("start")
|
||||
end = report_instructions[0].get("readback").get("end")
|
||||
if self.config.num_bars != len(devices):
|
||||
self.set_number_of_bars(len(devices))
|
||||
for index, device in enumerate(devices):
|
||||
self._hook_readback(index, device, start[index], end[index])
|
||||
else:
|
||||
logger.error(f"{instruction_type} not supported yet.")
|
||||
|
||||
def _hook_scan_progress(self, ring_index: int = None):
|
||||
def _hook_scan_progress(self, ring_index: int | None = None):
|
||||
"""
|
||||
Hook the scan progress to the progress bars.
|
||||
|
||||
@@ -521,8 +514,7 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
|
||||
if ring.config.connections.slot == "on_scan_progress":
|
||||
return
|
||||
else:
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
|
||||
def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int):
|
||||
"""
|
||||
@@ -576,6 +568,8 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
return bar_index
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not self._rings:
|
||||
return
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
size = min(self.width(), self.height())
|
||||
@@ -628,9 +622,8 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
return QSize(10, 10)
|
||||
ring_widths = [self.config.rings[i].line_width for i in range(self.config.num_bars)]
|
||||
total_width = sum(ring_widths) + self.config.gap * (self.config.num_bars - 1)
|
||||
diameter = total_width * 2
|
||||
if diameter < 50:
|
||||
diameter = 50
|
||||
diameter = max(total_width * 2, 50)
|
||||
|
||||
return QSize(diameter, diameter)
|
||||
|
||||
def sizeHint(self):
|
||||
|
||||
@@ -38,8 +38,8 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
@SafeSlot()
|
||||
def _refresh(self):
|
||||
if (dev := self.dev.get(self.device)) is not None:
|
||||
dev.read()
|
||||
dev.read_configuration()
|
||||
dev.read(cached=True)
|
||||
dev.read_configuration(cached=True)
|
||||
|
||||
def _add_refresh_button(self):
|
||||
button_holder = QWidget()
|
||||
|
||||
574
bec_widgets/widgets/utility/pdf_viewer/pdf_viewer.py
Normal file
574
bec_widgets/widgets/utility/pdf_viewer/pdf_viewer.py
Normal file
@@ -0,0 +1,574 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from qtpy.QtCore import QMargins, Qt, Signal
|
||||
from qtpy.QtGui import QIntValidator
|
||||
from qtpy.QtPdf import QPdfDocument
|
||||
from qtpy.QtPdfWidgets import QPdfView
|
||||
from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
|
||||
class PdfViewerWidget(BECWidget, QWidget):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
# Emitted when a PDF document is successfully loaded, providing the file path.
|
||||
document_ready = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "picture_as_pdf"
|
||||
USER_ACCESS = [
|
||||
"load_pdf",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"fit_to_width",
|
||||
"fit_to_page",
|
||||
"reset_zoom",
|
||||
"previous_page",
|
||||
"next_page",
|
||||
"toggle_continuous_scroll",
|
||||
"page_spacing",
|
||||
"page_spacing.setter",
|
||||
"side_margins",
|
||||
"side_margins.setter",
|
||||
"go_to_first_page",
|
||||
"go_to_last_page",
|
||||
"jump_to_page",
|
||||
"current_page",
|
||||
"current_file_path",
|
||||
"current_file_path.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, config=None, client=None, gui_id=None, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Set up the layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create the PDF document and view first
|
||||
self._pdf_document = QPdfDocument(self)
|
||||
self.pdf_view = QPdfView()
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
|
||||
# Create toolbar after PDF components are initialized
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self._setup_toolbar()
|
||||
|
||||
# Add widgets to layout
|
||||
layout.addWidget(self.toolbar)
|
||||
layout.addWidget(self.pdf_view)
|
||||
|
||||
# Current file path and spacing settings
|
||||
self._current_file_path = None
|
||||
self._page_spacing = 5 # Default spacing between pages in continuous mode
|
||||
self._side_margins = 10 # Default side margins (horizontal spacing)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
"""Set up the toolbar with PDF control buttons."""
|
||||
# Create separate bundles for different control groups
|
||||
file_bundle = self.toolbar.new_bundle("file_controls")
|
||||
zoom_bundle = self.toolbar.new_bundle("zoom_controls")
|
||||
view_bundle = self.toolbar.new_bundle("view_controls")
|
||||
nav_bundle = self.toolbar.new_bundle("navigation_controls")
|
||||
|
||||
# File operations
|
||||
open_action = MaterialIconAction(
|
||||
icon_name="folder_open", tooltip="Open PDF File", parent=self
|
||||
)
|
||||
open_action.action.triggered.connect(self.open_file_dialog)
|
||||
self.toolbar.components.add("open_file", open_action)
|
||||
file_bundle.add_action("open_file")
|
||||
|
||||
# Zoom controls
|
||||
zoom_in_action = MaterialIconAction(icon_name="zoom_in", tooltip="Zoom In", parent=self)
|
||||
zoom_in_action.action.triggered.connect(self.zoom_in)
|
||||
self.toolbar.components.add("zoom_in", zoom_in_action)
|
||||
zoom_bundle.add_action("zoom_in")
|
||||
|
||||
zoom_out_action = MaterialIconAction(icon_name="zoom_out", tooltip="Zoom Out", parent=self)
|
||||
zoom_out_action.action.triggered.connect(self.zoom_out)
|
||||
self.toolbar.components.add("zoom_out", zoom_out_action)
|
||||
zoom_bundle.add_action("zoom_out")
|
||||
|
||||
fit_width_action = MaterialIconAction(
|
||||
icon_name="fit_screen", tooltip="Fit to Width", parent=self
|
||||
)
|
||||
fit_width_action.action.triggered.connect(self.fit_to_width)
|
||||
self.toolbar.components.add("fit_width", fit_width_action)
|
||||
zoom_bundle.add_action("fit_width")
|
||||
|
||||
fit_page_action = MaterialIconAction(
|
||||
icon_name="fullscreen", tooltip="Fit to Page", parent=self
|
||||
)
|
||||
fit_page_action.action.triggered.connect(self.fit_to_page)
|
||||
self.toolbar.components.add("fit_page", fit_page_action)
|
||||
zoom_bundle.add_action("fit_page")
|
||||
|
||||
reset_zoom_action = MaterialIconAction(
|
||||
icon_name="center_focus_strong", tooltip="Reset Zoom to 100%", parent=self
|
||||
)
|
||||
reset_zoom_action.action.triggered.connect(self.reset_zoom)
|
||||
self.toolbar.components.add("reset_zoom", reset_zoom_action)
|
||||
zoom_bundle.add_action("reset_zoom")
|
||||
|
||||
# View controls
|
||||
continuous_scroll_action = MaterialIconAction(
|
||||
icon_name="view_agenda", tooltip="Toggle Continuous Scroll", checkable=True, parent=self
|
||||
)
|
||||
continuous_scroll_action.action.toggled.connect(self.toggle_continuous_scroll)
|
||||
self.toolbar.components.add("continuous_scroll", continuous_scroll_action)
|
||||
view_bundle.add_action("continuous_scroll")
|
||||
|
||||
# Navigation controls
|
||||
prev_page_action = MaterialIconAction(
|
||||
icon_name="navigate_before", tooltip="Previous Page", parent=self
|
||||
)
|
||||
prev_page_action.action.triggered.connect(self.previous_page)
|
||||
self.toolbar.components.add("prev_page", prev_page_action)
|
||||
nav_bundle.add_action("prev_page")
|
||||
|
||||
next_page_action = MaterialIconAction(
|
||||
icon_name="navigate_next", tooltip="Next Page", parent=self
|
||||
)
|
||||
next_page_action.action.triggered.connect(self.next_page)
|
||||
self.toolbar.components.add("next_page", next_page_action)
|
||||
nav_bundle.add_action("next_page")
|
||||
|
||||
# Page jump widget (in navigation bundle)
|
||||
self._setup_page_jump_widget(nav_bundle)
|
||||
|
||||
# Show all bundles
|
||||
self.toolbar.show_bundles(
|
||||
["file_controls", "zoom_controls", "view_controls", "navigation_controls"]
|
||||
)
|
||||
|
||||
# Initialize navigation button tooltips for single page mode (default)
|
||||
self._update_navigation_buttons_for_mode(continuous=False)
|
||||
|
||||
# Initialize navigation button states
|
||||
self._update_navigation_button_states()
|
||||
|
||||
def _setup_page_jump_widget(self, nav_bundle):
|
||||
"""Set up the page jump widget (label + line edit)."""
|
||||
# Create a container widget for the page jump controls
|
||||
page_jump_container = QWidget()
|
||||
page_jump_layout = QHBoxLayout(page_jump_container)
|
||||
page_jump_layout.setContentsMargins(5, 0, 5, 0)
|
||||
page_jump_layout.setSpacing(3)
|
||||
|
||||
# Page input field
|
||||
self.page_input = QLineEdit()
|
||||
self.page_input.setValidator(QIntValidator(1, 100000)) # restrict to 1–100000
|
||||
self.page_input.setFixedWidth(50)
|
||||
self.page_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.page_input.setPlaceholderText("1")
|
||||
self.page_input.setToolTip("Enter page number and press Enter")
|
||||
self.page_input.returnPressed.connect(self._line_edit_jump_to_page)
|
||||
|
||||
# Total pages label
|
||||
self.total_pages_label = QLabel("/ 1")
|
||||
self.total_pages_label.setStyleSheet("color: #666; font-size: 12px;")
|
||||
|
||||
# Add widgets to layout
|
||||
page_jump_layout.addWidget(self.page_input)
|
||||
page_jump_layout.addWidget(self.total_pages_label)
|
||||
|
||||
# Create a WidgetAction for the page jump controls
|
||||
# No manual separator needed - bundles are automatically separated
|
||||
page_jump_action = WidgetAction(
|
||||
label="Page:", widget=page_jump_container, adjust_size=False, parent=self
|
||||
)
|
||||
self.toolbar.components.add("page_jump", page_jump_action)
|
||||
nav_bundle.add_action("page_jump")
|
||||
|
||||
def _line_edit_jump_to_page(self):
|
||||
"""Jump to the page entered in the line edit."""
|
||||
page_text = self.page_input.text().strip()
|
||||
if not page_text:
|
||||
return
|
||||
# We validated input to be integer, so safe to convert directly
|
||||
self.jump_to_page(int(page_text))
|
||||
|
||||
def _update_navigation_button_states(self):
|
||||
"""Update the enabled/disabled state of navigation buttons."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
# No document loaded - disable all navigation
|
||||
self._set_navigation_enabled(False, False)
|
||||
self._update_page_display(1, 1)
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
total_pages = self._pdf_document.pageCount()
|
||||
|
||||
# Update button states
|
||||
prev_enabled = current_page > 0
|
||||
next_enabled = current_page < (total_pages - 1)
|
||||
self._set_navigation_enabled(prev_enabled, next_enabled)
|
||||
|
||||
# Update page display
|
||||
self._update_page_display(current_page + 1, total_pages)
|
||||
|
||||
def _set_navigation_enabled(self, prev_enabled: bool, next_enabled: bool):
|
||||
"""Set the enabled state of navigation buttons."""
|
||||
prev_action = self.toolbar.components.get_action("prev_page")
|
||||
if prev_action and hasattr(prev_action, "action") and prev_action.action:
|
||||
prev_action.action.setEnabled(prev_enabled)
|
||||
|
||||
next_action = self.toolbar.components.get_action("next_page")
|
||||
if next_action and hasattr(next_action, "action") and next_action.action:
|
||||
next_action.action.setEnabled(next_enabled)
|
||||
|
||||
def _update_page_display(self, current_page: int, total_pages: int):
|
||||
"""Update the page display in the toolbar."""
|
||||
if hasattr(self, "page_input"):
|
||||
self.page_input.setText(str(current_page))
|
||||
self.page_input.setPlaceholderText(str(current_page))
|
||||
|
||||
if hasattr(self, "total_pages_label"):
|
||||
self.total_pages_label.setText(f"/ {total_pages}")
|
||||
|
||||
@SafeProperty(str)
|
||||
def current_file_path(self):
|
||||
"""Get the current PDF file path."""
|
||||
return self._current_file_path
|
||||
|
||||
@current_file_path.setter
|
||||
def current_file_path(self, value: str):
|
||||
"""
|
||||
Set the current PDF file path and load the document.
|
||||
|
||||
Args:
|
||||
value (str): Path to the PDF file to load.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("current_file_path must be a string")
|
||||
self.load_pdf(value)
|
||||
|
||||
@SafeProperty(int)
|
||||
def page_spacing(self):
|
||||
"""Get the spacing between pages in continuous scroll mode."""
|
||||
return self._page_spacing
|
||||
|
||||
@property
|
||||
def current_page(self):
|
||||
"""Get the current page number (1-based index)."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return 0
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
return navigator.currentPage() + 1
|
||||
|
||||
@page_spacing.setter
|
||||
def page_spacing(self, value: int):
|
||||
"""
|
||||
Set the spacing between pages in continuous scroll mode.
|
||||
|
||||
Args:
|
||||
value (int): Spacing in pixels (non-negative integer).
|
||||
"""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("page_spacing must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError("page_spacing must be non-negative")
|
||||
|
||||
self._page_spacing = value
|
||||
|
||||
# If currently in continuous scroll mode, update the spacing immediately
|
||||
if self.pdf_view.pageMode() == QPdfView.PageMode.MultiPage:
|
||||
self.pdf_view.setPageSpacing(self._page_spacing)
|
||||
|
||||
@SafeProperty(int)
|
||||
def side_margins(self):
|
||||
"""Get the horizontal margins (side spacing) around the PDF content."""
|
||||
return self._side_margins
|
||||
|
||||
@side_margins.setter
|
||||
def side_margins(self, value: int):
|
||||
"""Set the horizontal margins (side spacing) around the PDF content."""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("side_margins must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError("side_margins must be non-negative")
|
||||
|
||||
self._side_margins = value
|
||||
|
||||
# Update the document margins immediately
|
||||
# setDocumentMargins takes a QMargins(left, top, right, bottom)
|
||||
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
|
||||
self.pdf_view.setDocumentMargins(margins)
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open a file dialog to select a PDF file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open PDF File", "", "PDF Files (*.pdf);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.load_pdf(file_path)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
Load a PDF file into the viewer.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the PDF file to load.
|
||||
"""
|
||||
# Validate file exists
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
self._current_file_path = file_path
|
||||
|
||||
# Disconnect any existing signal connections
|
||||
try:
|
||||
self._pdf_document.statusChanged.disconnect(self._on_document_status_changed)
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Connect to statusChanged signal to handle when document is ready
|
||||
self._pdf_document.statusChanged.connect(self._on_document_status_changed)
|
||||
|
||||
# Load the document
|
||||
self._pdf_document.load(file_path)
|
||||
|
||||
# If already ready (synchronous loading), set document immediately
|
||||
if self._pdf_document.status() == QPdfDocument.Status.Ready:
|
||||
self._on_document_ready()
|
||||
|
||||
@SafeSlot(QPdfDocument.Status)
|
||||
def _on_document_status_changed(self, status: QPdfDocument.Status):
|
||||
"""Handle document status changes."""
|
||||
status = self._pdf_document.status()
|
||||
|
||||
if status == QPdfDocument.Status.Ready:
|
||||
self._on_document_ready()
|
||||
elif status == QPdfDocument.Status.Error:
|
||||
raise RuntimeError(f"Failed to load PDF document: {self._current_file_path}")
|
||||
|
||||
def _on_document_ready(self):
|
||||
"""Handle when document is ready to be displayed."""
|
||||
self.pdf_view.setDocument(self._pdf_document)
|
||||
|
||||
# Set initial margins
|
||||
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
|
||||
self.pdf_view.setDocumentMargins(margins)
|
||||
|
||||
# Connect to page changes to update navigation button states
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.currentPageChanged.connect(self._on_page_changed)
|
||||
|
||||
# Make sure we start at the first page
|
||||
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
# Update initial navigation state
|
||||
self._update_navigation_button_states()
|
||||
self.document_ready.emit(self._current_file_path)
|
||||
|
||||
def _on_page_changed(self, _page):
|
||||
"""Handle page change events to update navigation states."""
|
||||
self._update_navigation_button_states()
|
||||
|
||||
# Toolbar action methods
|
||||
@SafeSlot()
|
||||
def zoom_in(self):
|
||||
"""Zoom in the PDF view."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
current_factor = self.pdf_view.zoomFactor()
|
||||
new_factor = current_factor * 1.25
|
||||
self.pdf_view.setZoomFactor(new_factor)
|
||||
|
||||
@SafeSlot()
|
||||
def zoom_out(self):
|
||||
"""Zoom out the PDF view."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
current_factor = self.pdf_view.zoomFactor()
|
||||
new_factor = max(current_factor / 1.25, 0.1)
|
||||
self.pdf_view.setZoomFactor(new_factor)
|
||||
|
||||
@SafeSlot()
|
||||
def fit_to_width(self):
|
||||
"""Fit PDF to width."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
|
||||
@SafeSlot()
|
||||
def fit_to_page(self):
|
||||
"""Fit PDF to page."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitInView)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_zoom(self):
|
||||
"""Reset zoom to 100% (1.0 factor)."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
self.pdf_view.setZoomFactor(1.0)
|
||||
|
||||
@SafeSlot()
|
||||
def previous_page(self):
|
||||
"""Go to previous page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
if current_page == 0:
|
||||
self._update_navigation_button_states()
|
||||
return
|
||||
|
||||
try:
|
||||
target_page = current_page - 1
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: Use scroll to approximate position
|
||||
page_height = self.pdf_view.viewport().height()
|
||||
self.pdf_view.verticalScrollBar().setValue(
|
||||
self.pdf_view.verticalScrollBar().value() - page_height
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update navigation button states (in case signal doesn't fire)
|
||||
self._update_navigation_button_states()
|
||||
|
||||
@SafeSlot()
|
||||
def next_page(self):
|
||||
"""Go to next page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
max_page = self._pdf_document.pageCount() - 1
|
||||
if current_page < max_page:
|
||||
try:
|
||||
target_page = current_page + 1
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: Use scroll to approximate position
|
||||
page_height = self.pdf_view.viewport().height()
|
||||
self.pdf_view.verticalScrollBar().setValue(
|
||||
self.pdf_view.verticalScrollBar().value() + page_height
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update navigation button states (in case signal doesn't fire)
|
||||
self._update_navigation_button_states()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def toggle_continuous_scroll(self, checked: bool):
|
||||
"""
|
||||
Toggle between single page and continuous scroll mode.
|
||||
|
||||
Args:
|
||||
checked (bool): True to enable continuous scroll, False for single page mode.
|
||||
"""
|
||||
if checked:
|
||||
self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self.pdf_view.setPageSpacing(self._page_spacing)
|
||||
self._update_navigation_buttons_for_mode(continuous=True)
|
||||
tooltip = "Switch to Single Page Mode"
|
||||
else:
|
||||
self.pdf_view.setPageMode(QPdfView.PageMode.SinglePage)
|
||||
self._update_navigation_buttons_for_mode(continuous=False)
|
||||
tooltip = "Switch to Continuous Scroll Mode"
|
||||
|
||||
# Update navigation button states after mode change
|
||||
self._update_navigation_button_states()
|
||||
|
||||
# Update toggle button tooltip to reflect current state
|
||||
action = self.toolbar.components.get_action("continuous_scroll")
|
||||
if action and hasattr(action, "action") and action.action:
|
||||
action.action.setToolTip(tooltip)
|
||||
|
||||
def _update_navigation_buttons_for_mode(self, continuous: bool):
|
||||
"""Update navigation button tooltips based on current mode."""
|
||||
prev_action = self.toolbar.components.get_action("prev_page")
|
||||
next_action = self.toolbar.components.get_action("next_page")
|
||||
|
||||
if continuous:
|
||||
prev_actions_tooltip = "Previous Page (use scroll in continuous mode)"
|
||||
next_actions_tooltip = "Next Page (use scroll in continuous mode)"
|
||||
else:
|
||||
prev_actions_tooltip = "Previous Page"
|
||||
next_actions_tooltip = "Next Page"
|
||||
|
||||
if prev_action and hasattr(prev_action, "action") and prev_action.action:
|
||||
prev_action.action.setToolTip(prev_actions_tooltip)
|
||||
if next_action and hasattr(next_action, "action") and next_action.action:
|
||||
next_action.action.setToolTip(next_actions_tooltip)
|
||||
|
||||
@SafeSlot()
|
||||
def go_to_first_page(self):
|
||||
"""Go to the first page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
@SafeSlot()
|
||||
def go_to_last_page(self):
|
||||
"""Go to the last page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
last_page = self._pdf_document.pageCount() - 1
|
||||
navigator.update(last_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
@SafeSlot(int)
|
||||
def jump_to_page(self, page_number: int):
|
||||
"""Jump to a specific page number (1-based index)."""
|
||||
if not isinstance(page_number, int):
|
||||
raise ValueError("page_number must be an integer")
|
||||
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
raise RuntimeError("No PDF document loaded")
|
||||
|
||||
max_page = self._pdf_document.pageCount()
|
||||
page_number = max(min(page_number, max_page), 1)
|
||||
|
||||
target_page = page_number - 1 # Convert to 0-based index
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
def cleanup(self):
|
||||
"""Handle widget close event to prevent segfaults."""
|
||||
if hasattr(self, "_pdf_document") and self._pdf_document:
|
||||
self._pdf_document.statusChanged.disconnect()
|
||||
empty_doc = QPdfDocument(self)
|
||||
self.pdf_view.setDocument(empty_doc)
|
||||
|
||||
if hasattr(self, "toolbar"):
|
||||
self.toolbar.cleanup()
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
# apply_theme("dark")
|
||||
viewer = PdfViewerWidget()
|
||||
# viewer.load_pdf("/Path/To/Your/TestDocument.pdf")
|
||||
viewer.next_page()
|
||||
# viewer.page_spacing = 0
|
||||
# viewer.side_margins = 0
|
||||
viewer.resize(1000, 700)
|
||||
viewer.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['pdf_viewer.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='PdfViewerWidget' name='pdf_viewer_widget'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class PdfViewerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = PdfViewerWidget(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Utils"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(PdfViewerWidget.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "pdf_viewer_widget"
|
||||
|
||||
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 "PdfViewerWidget"
|
||||
|
||||
def toolTip(self):
|
||||
return "A widget to display PDF documents with toolbar controls."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -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.pdf_viewer.pdf_viewer_widget_plugin import (
|
||||
PdfViewerWidgetPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(PdfViewerWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -273,7 +273,9 @@ class SignalLabel(BECWidget, QWidget):
|
||||
if not isinstance(self._device_obj, Device | Signal):
|
||||
self._value, self._units = "__", ""
|
||||
return
|
||||
reading = (self._device_obj.read() or {}) | (self._device_obj.read_configuration() or {})
|
||||
reading = (self._device_obj.read(cached=True) or {}) | (
|
||||
self._device_obj.read_configuration(cached=True) or {}
|
||||
)
|
||||
value = reading.get(self._signal_key, {}).get("value")
|
||||
if value is None:
|
||||
self._value, self._units = "__", ""
|
||||
|
||||
BIN
docs/assets/widget_screenshots/pdf_viewer.png
Normal file
BIN
docs/assets/widget_screenshots/pdf_viewer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
@@ -8,7 +8,7 @@ Therefore, we recommend that you install BEC first following the [developer inst
|
||||
If you already have a BEC environment set up, you can install BEC Widgets in editable mode into your BEC Python environment.
|
||||
|
||||
**Prerequisites**
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.11 or higher. Verify your Python version to ensure compatibility.
|
||||
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
3. **Qt Distributions:** BEC Widgets supports [PySide6](https://doc.qt.io/qtforpython-6/quickstart.html) and [PyQt6](https://www.riverbankcomputing.com/static/Docs/PyQt6/introduction.html). We use [qtpy](https://pypi.org/project/QtPy/) to abstract the underlying QT distribution.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Before installing BEC Widgets, please ensure the following requirements are met:
|
||||
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.11 or higher. Verify your Python version to ensure compatibility.
|
||||
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
**Standard Installation**
|
||||
|
||||
119
docs/user/widgets/pdf_viewer/pdf_viewer_widget.md
Normal file
119
docs/user/widgets/pdf_viewer/pdf_viewer_widget.md
Normal file
@@ -0,0 +1,119 @@
|
||||
(user.widgets.pdf_viewer_widget)=
|
||||
|
||||
# PDF Viewer Widget
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The PDF Viewer Widget is a versatile tool designed for displaying and navigating PDF documents within your BEC applications. Directly integrated with the `BEC` framework, it provides a full-featured PDF viewing experience with zoom controls, page navigation, and customizable display options.
|
||||
|
||||
## Key Features:
|
||||
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
|
||||
- **Full PDF Support**: Display any PDF document with full rendering support through Qt's PDF rendering engine.
|
||||
- **Navigation Controls**: Built-in toolbar with page navigation, zoom controls, and document status indicators.
|
||||
- **Customizable Display**: Adjustable page spacing, margins, and zoom levels for optimal viewing experience.
|
||||
- **Document Management**: Load different PDF files dynamically during runtime with proper error handling.
|
||||
|
||||
## User Interface Components:
|
||||
- **Toolbar**: Contains all navigation and zoom controls
|
||||
- Previous/Next page buttons
|
||||
- Page number input field with total page count
|
||||
- First/Last page navigation buttons
|
||||
- Zoom in/out buttons
|
||||
- Fit to width/page buttons
|
||||
- Reset zoom button
|
||||
- **PDF View Area**: Main display area for the PDF content
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - CLI
|
||||
|
||||
`PdfViewerWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
|
||||
|
||||
## Example 1 - Basic PDF Loading
|
||||
|
||||
In this example, we demonstrate how to add a `PdfViewerWidget` to a [`BECDockArea`](user.widgets.bec_dock_area) and load a PDF document.
|
||||
|
||||
```python
|
||||
# Add a new dock with PDF viewer widget
|
||||
dock_area = gui.new()
|
||||
pdf_viewer = dock_area.new().new(gui.available_widgets.PdfViewerWidget)
|
||||
|
||||
# Load a PDF file
|
||||
pdf_viewer.load_pdf("/path/to/your/document.pdf")
|
||||
```
|
||||
|
||||
## Example 2 - Customizing Display Properties
|
||||
|
||||
This example shows how to customize the display properties of the PDF viewer for better presentation.
|
||||
|
||||
```python
|
||||
# Create PDF viewer
|
||||
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
|
||||
|
||||
# Load PDF document
|
||||
pdf_viewer.load_pdf("/path/to/report.pdf")
|
||||
pdf_viewer.toggle_continuous_scroll(True) # Enable continuous scroll mode
|
||||
|
||||
# Customize display properties
|
||||
pdf_viewer.page_spacing = 20 # Increase spacing between pages
|
||||
pdf_viewer.side_margins = 50 # Add horizontal margins
|
||||
|
||||
# Navigate to specific page
|
||||
pdf_viewer.jump_to_page(5) # Go to page 5
|
||||
```
|
||||
|
||||
## Example 3 - Navigation and Zoom Controls
|
||||
|
||||
The PDF viewer provides programmatic access to all navigation and zoom functionality.
|
||||
|
||||
```python
|
||||
# Create and load PDF
|
||||
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
|
||||
pdf_viewer.load_pdf("/path/to/manual.pdf")
|
||||
|
||||
# Navigation examples
|
||||
pdf_viewer.go_to_first_page() # Go to first page
|
||||
pdf_viewer.go_to_last_page() # Go to last page
|
||||
pdf_viewer.jump_to_page(10) # Jump to specific page
|
||||
|
||||
# Zoom controls
|
||||
pdf_viewer.zoom_in() # Increase zoom
|
||||
pdf_viewer.zoom_out() # Decrease zoom
|
||||
pdf_viewer.fit_to_width() # Fit document to window width
|
||||
pdf_viewer.fit_to_page() # Fit entire page to window
|
||||
pdf_viewer.reset_zoom() # Reset to 100% zoom
|
||||
|
||||
# Check current status
|
||||
current_page = pdf_viewer.current_page
|
||||
print(f"Currently viewing page {current_page}")
|
||||
```
|
||||
|
||||
## Example 4 - Dynamic Document Loading
|
||||
|
||||
This example demonstrates how to switch between different PDF documents dynamically.
|
||||
|
||||
```python
|
||||
# Create PDF viewer
|
||||
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
|
||||
|
||||
# Load first document
|
||||
pdf_viewer.load_pdf("/path/to/document1.pdf")
|
||||
|
||||
# Or simply set the current file path
|
||||
pdf_viewer.current_file_path = "/path/to/document2.pdf"
|
||||
# This automatically loads the new document
|
||||
|
||||
# Check which file is currently loaded
|
||||
current_file = pdf_viewer.current_file_path
|
||||
print(f"Currently viewing: {current_file}")
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. autoclass:: bec_widgets.cli.client.PdfViewerWidget
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -270,6 +270,14 @@ Select DAP model from a list of DAP processes.
|
||||
|
||||
Show and filter logs from the BEC Redis server.
|
||||
```
|
||||
|
||||
```{grid-item-card} PDF Viewer Widget
|
||||
:link: user.widgets.pdf_viewer_widget
|
||||
:link-type: ref
|
||||
:img-top: /assets/widget_screenshots/pdf_viewer.png
|
||||
|
||||
Display and navigate PDF documents.
|
||||
```
|
||||
````
|
||||
|
||||
```{toctree}
|
||||
@@ -307,6 +315,7 @@ dap_combo_box/dap_combo_box.md
|
||||
games/games.md
|
||||
log_panel/log_panel.md
|
||||
signal_label/signal_label.md
|
||||
pdf_viewer/pdf_viewer_widget.md
|
||||
|
||||
|
||||
```
|
||||
@@ -4,22 +4,22 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.38.2"
|
||||
version = "2.45.6"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client~=3.52", # needed for jupyter console
|
||||
"bec_lib~=3.52",
|
||||
"bec_ipython_client~=3.70", # needed for jupyter console
|
||||
"bec_lib~=3.70",
|
||||
"bec_qthemes~=0.7, >=0.7",
|
||||
"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",
|
||||
"pyqtgraph==0.13.7",
|
||||
"PySide6==6.9.0",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
@@ -32,7 +32,6 @@ 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",
|
||||
|
||||
@@ -286,3 +286,85 @@ def test_waveform_passing_device(qtbot, bec_client_lib, connected_client_gui_obj
|
||||
# check plotted data
|
||||
x_data, y_data = c1.get_data()
|
||||
assert np.array_equal(y_data, last_scan_data.devices.samx.samx_setpoint.read().get("value"))
|
||||
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.parametrize(
|
||||
"history_selector", ["scan_id", "scan_number"]
|
||||
) # ensure unique curves per run
|
||||
def test_rpc_waveform_history_curve(
|
||||
qtbot, bec_client_lib, connected_client_gui_obj, history_selector
|
||||
):
|
||||
"""
|
||||
E2E test for the new history curve feature:
|
||||
- Run 3 scans
|
||||
- For each scan, fetch history curve data using either scan_id OR scan_number (parametrized)
|
||||
- Compare waveform data with BEC client scan data
|
||||
Note: Parameterization prevents adding the same logical curve twice (which would collide on label).
|
||||
"""
|
||||
gui = connected_client_gui_obj
|
||||
dock = gui.bec
|
||||
client = bec_client_lib
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
wf = dock.new("wf_dock").new("Waveform")
|
||||
|
||||
# Collect references for validation
|
||||
scan_meta = [] # list of dicts with scan_id, scan_number, data
|
||||
|
||||
# Run 3 scans and collect their metadata and data
|
||||
for i in range(3):
|
||||
status = scans.line_scan(dev.samx, -5 + i, 5 + i, steps=10, exp_time=0.01, relative=False)
|
||||
status.wait()
|
||||
|
||||
# Wait until the history entry appears and corresponds to this scan
|
||||
def _wait_for_scan_in_history():
|
||||
if len(client.history) == 0:
|
||||
return False
|
||||
return client.history[-1].metadata.bec.get("scan_id", None) == status.scan.scan_id
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=10000)
|
||||
|
||||
hist_item = client.history[-1]
|
||||
item = queue.scan_storage.storage[-1]
|
||||
data = item.live_data if hasattr(item, "live_data") else item.data
|
||||
scan_meta.append(
|
||||
{
|
||||
"scan_id": hist_item.metadata.bec.get("scan_id"),
|
||||
"scan_number": hist_item.metadata.bec.get("scan_number"),
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
# For each scan, fetch history curve by the chosen selector and compare to client data
|
||||
for meta in scan_meta:
|
||||
sel_value = meta[history_selector]
|
||||
scan_data = meta["data"]
|
||||
|
||||
# Add curve from history using the chosen selector; single curve per scan to avoid duplicates
|
||||
kwargs = {history_selector: sel_value}
|
||||
curve = wf.plot(x_name="samx", y_name="bpm4i", **kwargs)
|
||||
|
||||
num_elements = 10
|
||||
|
||||
# Wait until curve has the expected number of points
|
||||
def _curve_ready():
|
||||
try:
|
||||
x, y = curve.get_data()
|
||||
except Exception:
|
||||
return False
|
||||
return x is not None and len(x) == num_elements and len(y) == num_elements
|
||||
|
||||
qtbot.waitUntil(_curve_ready, timeout=10000)
|
||||
|
||||
# Get plotted data
|
||||
x_vals, y_vals = curve.get_data()
|
||||
|
||||
# Compare against BEC client scan data
|
||||
np.testing.assert_equal(x_vals, np.array(scan_data["samx"]["samx"].val))
|
||||
np.testing.assert_equal(y_vals, np.array(scan_data["bpm4i"]["bpm4i"].val))
|
||||
|
||||
# Clean up
|
||||
curve.remove()
|
||||
|
||||
@@ -371,6 +371,13 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
|
||||
) # Get last image from Redis monitor 2D endpoint
|
||||
assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# Now add a device with a preview signal
|
||||
img = widget.image(["eiger", "preview"])
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -577,6 +584,13 @@ def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_g
|
||||
dock: client.BECDock
|
||||
widget: client.RingProgressBar
|
||||
|
||||
widget.set_number_of_bars(3)
|
||||
widget.rings[0].set_update("manual")
|
||||
widget.rings[0].set_value(30)
|
||||
widget.rings[0].set_min_max_values(0, 100)
|
||||
widget.rings[1].set_update("scan")
|
||||
widget.rings[2].set_update("device", device="samx")
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
|
||||
@@ -7,11 +7,12 @@ import pytest
|
||||
from bec_lib.bec_service import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
|
||||
|
||||
|
||||
def fake_redis_server(host, port):
|
||||
def fake_redis_server(host, port, **kwargs):
|
||||
redis = fakeredis.FakeRedis()
|
||||
return redis
|
||||
|
||||
@@ -238,3 +239,18 @@ def create_dummy_scan_item():
|
||||
"scan_report_devices": ["samx"],
|
||||
}
|
||||
return dummy_scan
|
||||
|
||||
|
||||
def inject_scan_history(widget, scan_history_factory, *history_args):
|
||||
"""
|
||||
Helper to inject scan history messages into client history.
|
||||
"""
|
||||
history_msgs = []
|
||||
for scan_id, scan_number in history_args:
|
||||
history_msgs.append(scan_history_factory(scan_id=scan_id, scan_number=scan_number))
|
||||
widget.client.history = ScanHistory(widget.client, False)
|
||||
for msg in history_msgs:
|
||||
widget.client.history._scan_data[msg.scan_id] = msg
|
||||
widget.client.history._scan_ids.append(msg.scan_id)
|
||||
widget.client.queue.scan_storage.current_scan = None
|
||||
return history_msgs
|
||||
|
||||
@@ -5,8 +5,9 @@ import h5py
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib.messages import _StoredDataInfo
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
@@ -69,6 +70,14 @@ def clean_singleton():
|
||||
error_popups._popup_utility_instance = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def suppress_message_box(monkeypatch):
|
||||
"""
|
||||
Auto-suppress any QMessageBox.exec_ calls by returning Ok immediately.
|
||||
"""
|
||||
monkeypatch.setattr(QMessageBox, "exec_", lambda *args, **kwargs: QMessageBox.Ok)
|
||||
|
||||
|
||||
def create_widget(qtbot, widget, *args, **kwargs):
|
||||
"""
|
||||
Create a widget and add it to the qtbot for testing. This is a helper function that
|
||||
@@ -115,9 +124,25 @@ def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanH
|
||||
elif isinstance(sub_value, dict):
|
||||
for sub_sub_key, sub_sub_value in sub_value.items():
|
||||
sub_sub_group = metadata_bec[key].create_group(sub_key)
|
||||
# Handle _StoredDataInfo objects
|
||||
if isinstance(sub_sub_value, _StoredDataInfo):
|
||||
# Store the numeric shape
|
||||
sub_sub_group.create_dataset("shape", data=sub_sub_value.shape)
|
||||
# Store the dtype as a UTF-8 string
|
||||
dt = sub_sub_value.dtype or ""
|
||||
sub_sub_group.create_dataset(
|
||||
"dtype", data=dt, dtype=h5py.string_dtype(encoding="utf-8")
|
||||
)
|
||||
continue
|
||||
if isinstance(sub_sub_value, list):
|
||||
sub_sub_value = json.dumps(sub_sub_value)
|
||||
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
|
||||
json_val = json.dumps(sub_sub_value)
|
||||
sub_sub_group.create_dataset(sub_sub_key, data=json_val)
|
||||
elif isinstance(sub_sub_value, dict):
|
||||
for k2, v2 in sub_sub_value.items():
|
||||
val = json.dumps(v2) if isinstance(v2, list) else v2
|
||||
sub_sub_group.create_dataset(k2, data=val)
|
||||
else:
|
||||
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
|
||||
else:
|
||||
metadata_bec[key].create_dataset(sub_key, data=sub_value)
|
||||
else:
|
||||
@@ -144,6 +169,8 @@ def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanH
|
||||
end_time=time.time(),
|
||||
num_points=metadata["num_points"],
|
||||
request_inputs=metadata["request_inputs"],
|
||||
stored_data_info=metadata.get("stored_data_info"),
|
||||
metadata={"scan_report_devices": metadata.get("scan_report_devices")},
|
||||
)
|
||||
return msg
|
||||
|
||||
@@ -194,3 +221,102 @@ def grid_scan_history_msg(tmpdir):
|
||||
|
||||
file_path = str(tmpdir.join("scan_1.h5"))
|
||||
return create_history_file(file_path, data, metadata)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_factory(tmpdir):
|
||||
"""
|
||||
Factory to create scan history messages with custom parameters.
|
||||
Usage:
|
||||
msg1 = scan_history_factory(scan_id="id1", scan_number=1, num_points=10)
|
||||
msg2 = scan_history_factory(scan_id="id2", scan_number=2, scan_name="grid_scan", num_points=16)
|
||||
"""
|
||||
|
||||
def _factory(
|
||||
scan_id: str = "test_scan",
|
||||
scan_number: int = 1,
|
||||
dataset_number: int = 1,
|
||||
scan_name: str = "line_scan",
|
||||
scan_type: str = "step",
|
||||
num_points: int = 10,
|
||||
x_range: tuple = (-5, 5),
|
||||
y_range: tuple = (-5, 5),
|
||||
):
|
||||
# Generate positions based on scan type
|
||||
if scan_name == "grid_scan":
|
||||
grid_size = int(np.sqrt(num_points))
|
||||
x_grid, y_grid = np.meshgrid(
|
||||
np.linspace(x_range[0], x_range[1], grid_size),
|
||||
np.linspace(y_range[0], y_range[1], grid_size),
|
||||
)
|
||||
x_flat = x_grid.T.ravel()
|
||||
y_flat = y_grid.T.ravel()
|
||||
else:
|
||||
x_flat = np.linspace(x_range[0], x_range[1], num_points)
|
||||
y_flat = np.linspace(y_range[0], y_range[1], num_points)
|
||||
positions = np.vstack((x_flat, y_flat)).T
|
||||
num_pts = len(positions)
|
||||
# Create dummy data
|
||||
data = {
|
||||
"baseline": {"bpm1a": {"bpm1a": {"value": [1], "timestamp": [100]}}},
|
||||
"monitored": {
|
||||
"bpm4i": {
|
||||
"bpm4i": {
|
||||
"value": np.random.rand(num_points),
|
||||
"timestamp": np.random.rand(num_points),
|
||||
}
|
||||
},
|
||||
"bpm3a": {
|
||||
"bpm3a": {
|
||||
"value": np.random.rand(num_points),
|
||||
"timestamp": np.random.rand(num_points),
|
||||
}
|
||||
},
|
||||
"samx": {"samx": {"value": x_flat, "timestamp": np.arange(num_pts)}},
|
||||
"samy": {"samy": {"value": y_flat, "timestamp": np.arange(num_pts)}},
|
||||
},
|
||||
"async": {
|
||||
"async_device": {
|
||||
"async_device": {
|
||||
"value": np.random.rand(num_pts * 10),
|
||||
"timestamp": np.random.rand(num_pts * 10),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
metadata = {
|
||||
"scan_id": scan_id,
|
||||
"scan_name": scan_name,
|
||||
"scan_type": scan_type,
|
||||
"exit_status": "closed",
|
||||
"scan_number": scan_number,
|
||||
"dataset_number": dataset_number,
|
||||
"request_inputs": {
|
||||
"arg_bundle": [
|
||||
"samx",
|
||||
x_range[0],
|
||||
x_range[1],
|
||||
num_pts,
|
||||
"samy",
|
||||
y_range[0],
|
||||
y_range[1],
|
||||
num_pts,
|
||||
],
|
||||
"kwargs": {"relative": True},
|
||||
},
|
||||
"positions": positions.tolist(),
|
||||
"num_points": num_pts,
|
||||
"stored_data_info": {
|
||||
"samx": {"samx": _StoredDataInfo(shape=(num_points,), dtype="float64")},
|
||||
"samy": {"samy": _StoredDataInfo(shape=(num_points,), dtype="float64")},
|
||||
"bpm4i": {"bpm4i": _StoredDataInfo(shape=(10,), dtype="float64")},
|
||||
"async_device": {
|
||||
"async_device": _StoredDataInfo(shape=(num_points * 10,), dtype="float64")
|
||||
},
|
||||
},
|
||||
"scan_report_devices": [b"samx"],
|
||||
}
|
||||
file_path = str(tmpdir.join(f"{scan_id}.h5"))
|
||||
return create_history_file(file_path, data, metadata)
|
||||
|
||||
return _factory
|
||||
|
||||
@@ -28,8 +28,7 @@ def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
|
||||
bec_logger.logger.warning.assert_called_with(
|
||||
"Plugin widget Widgets from namespace(Widgets=<class 'tests.unit_tests.test_client_plugin_widgets._TestGlobalPlugin'>) conflicts with a built-in class!"
|
||||
)
|
||||
if sys.version_info >= (3, 11): # No EnumType in python3.10
|
||||
assert isinstance(client.Widgets, enum.EnumType)
|
||||
assert isinstance(client.Widgets, enum.EnumType)
|
||||
|
||||
|
||||
class _TestDuplicatePlugin(RPCBase): ...
|
||||
|
||||
@@ -193,21 +193,6 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
|
||||
assert np.isclose(y, 5)
|
||||
|
||||
|
||||
def test_marker_positions_after_mouse_move(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
marker = crosshair.marker_moved_1d["Curve 1"]
|
||||
marker_x, marker_y = marker.getData()
|
||||
assert marker_x == [2]
|
||||
assert marker_y == [5]
|
||||
|
||||
|
||||
def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@ import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
from qtpy.QtGui import QValidator
|
||||
from qtpy.QtWidgets import QComboBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
|
||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import (
|
||||
CurveTree,
|
||||
ScanIndexValidator,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
@@ -155,7 +160,7 @@ def test_curve_tree_init(curve_tree_fixture):
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
assert curve_tree.waveform == wf
|
||||
assert curve_tree.color_palette == "plasma"
|
||||
assert curve_tree.tree.columnCount() == 7
|
||||
assert curve_tree.tree.columnCount() == 8
|
||||
|
||||
assert curve_tree.toolbar.components.exists("add")
|
||||
assert curve_tree.toolbar.components.exists("expand")
|
||||
@@ -374,3 +379,54 @@ def test_export_data_dap(curve_tree_fixture):
|
||||
assert exported["signal"]["entry"] == "bpm4i"
|
||||
assert exported["signal"]["dap"] == "GaussianModel"
|
||||
assert exported["label"] == "bpm4i-bpm4i-GaussianModel"
|
||||
|
||||
|
||||
def test_scan_index_validator_behavior():
|
||||
"""
|
||||
Test ScanIndexValidator allows empty, 'live', partial 'live', valid scan numbers,
|
||||
and rejects invalid or disallowed inputs under the new allowed-set API.
|
||||
"""
|
||||
validator = ScanIndexValidator(allowed_scans={1, 2, 3})
|
||||
|
||||
def state(txt):
|
||||
s, _, _ = validator.validate(txt, 0)
|
||||
return s
|
||||
|
||||
assert state("") == QValidator.State.Acceptable
|
||||
assert state("live") == QValidator.State.Acceptable
|
||||
assert state("l") == QValidator.State.Intermediate
|
||||
assert state("liv") == QValidator.State.Intermediate
|
||||
assert state("1") == QValidator.State.Acceptable
|
||||
assert state("3") == QValidator.State.Acceptable
|
||||
assert state("4") == QValidator.State.Invalid
|
||||
assert state("0") == QValidator.State.Invalid
|
||||
assert state("abc") == QValidator.State.Invalid
|
||||
|
||||
|
||||
def test_export_data_history_curve(curve_tree_fixture, scan_history_factory):
|
||||
"""
|
||||
Test that export_data for a history curve row correctly serializes scan_number
|
||||
and resets scan_id when a numeric scan is selected.
|
||||
"""
|
||||
curve_tree, wf = curve_tree_fixture
|
||||
# Inject two history scans into the waveform client
|
||||
msgs = [
|
||||
scan_history_factory(scan_id="hid1", scan_number=1),
|
||||
scan_history_factory(scan_id="hid2", scan_number=2),
|
||||
]
|
||||
wf.client.history = ScanHistory(wf.client, False)
|
||||
for m in msgs:
|
||||
wf.client.history._scan_data[m.scan_id] = m
|
||||
wf.client.history._scan_ids.append(m.scan_id)
|
||||
wf.client.history._scan_numbers.append(m.scan_number)
|
||||
wf.client.queue.scan_storage.current_scan = None
|
||||
|
||||
# Create a device row and select scan index "2"
|
||||
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
|
||||
device_row.scan_index_combo.setCurrentText("2")
|
||||
|
||||
exported = device_row.export_data()
|
||||
assert exported["source"] == "history"
|
||||
assert exported["scan_number"] == 2
|
||||
assert exported["scan_id"] is None
|
||||
assert exported["label"] == "bpm4i-bpm4i-scan-2"
|
||||
|
||||
@@ -137,6 +137,7 @@ def test_update_cycle(update_dialog, qtbot):
|
||||
"description": None,
|
||||
"readOnly": False,
|
||||
"softwareTrigger": False,
|
||||
"onFailure": "retry",
|
||||
"deviceTags": set(),
|
||||
"userParameter": {},
|
||||
"name": "test_device",
|
||||
|
||||
@@ -9,7 +9,6 @@ from bec_widgets.utils.forms_from_types.items import FormItemSpec, ListFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Generic types don't support this in 3.10")
|
||||
@pytest.mark.parametrize(
|
||||
["input", "validity"],
|
||||
[
|
||||
|
||||
@@ -29,6 +29,14 @@ def roi_tree(qtbot, image_widget):
|
||||
yield tree
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compact_roi_tree(qtbot, image_widget):
|
||||
tree = create_widget(
|
||||
qtbot, ROIPropertyTree, image_widget=image_widget, compact=True, compact_color="#00BCD4"
|
||||
)
|
||||
yield tree
|
||||
|
||||
|
||||
def test_initialization(roi_tree, image_widget):
|
||||
"""Test that the widget initializes correctly with the right components."""
|
||||
# Check the widget has the right structure
|
||||
@@ -431,3 +439,120 @@ def test_cleanup_disconnect_signals(roi_tree, image_widget):
|
||||
# Verify that the tree item was not updated
|
||||
assert item.text(roi_tree.COL_ROI) == initial_name
|
||||
assert item.child(2).text(roi_tree.COL_PROPS) == initial_coord
|
||||
|
||||
|
||||
def test_compact_initialization_minimal_toolbar(compact_roi_tree):
|
||||
assert compact_roi_tree.compact is True
|
||||
assert compact_roi_tree.tree is None
|
||||
|
||||
# Draw actions exist
|
||||
assert compact_roi_tree.toolbar.components.get_action("roi_rectangle")
|
||||
assert compact_roi_tree.toolbar.components.get_action("roi_circle")
|
||||
assert compact_roi_tree.toolbar.components.get_action("roi_ellipse")
|
||||
|
||||
# Full-mode actions are absent
|
||||
import pytest
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
compact_roi_tree.toolbar.components.get_action("expand_toggle")
|
||||
with pytest.raises(KeyError):
|
||||
compact_roi_tree.toolbar.components.get_action("lock_unlock_all")
|
||||
with pytest.raises(KeyError):
|
||||
compact_roi_tree.toolbar.components.get_action("roi_tree_cmap")
|
||||
|
||||
assert not hasattr(compact_roi_tree, "lock_all_action")
|
||||
|
||||
|
||||
def test_compact_single_roi_enforced_programmatic(compact_roi_tree, image_widget):
|
||||
# Add first ROI
|
||||
roi1 = image_widget.add_roi(kind="rect", name="r1")
|
||||
assert len(image_widget.roi_controller.rois) == 1
|
||||
assert roi1.line_color == "#00BCD4"
|
||||
|
||||
# Add second ROI; the first should be removed automatically
|
||||
roi2 = image_widget.add_roi(kind="circle", name="c1")
|
||||
rois = image_widget.roi_controller.rois
|
||||
assert len(rois) == 1
|
||||
assert rois[0] is roi2
|
||||
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI
|
||||
|
||||
assert isinstance(rois[0], CircularROI)
|
||||
assert rois[0].line_color == "#00BCD4"
|
||||
|
||||
|
||||
def test_compact_add_roi_from_toolbar_single_enforced(qtbot, compact_roi_tree, image_widget):
|
||||
# Ensure view is ready
|
||||
plot_item = image_widget.plot_item
|
||||
view = plot_item.vb.scene().views()[0]
|
||||
qtbot.waitExposed(view)
|
||||
|
||||
# Activate rectangle drawing
|
||||
rect_action = compact_roi_tree.toolbar.components.get_action("roi_rectangle").action
|
||||
rect_action.setChecked(True)
|
||||
|
||||
# Draw rectangle
|
||||
start_pos = QPointF(10, 10)
|
||||
end_pos = QPointF(50, 40)
|
||||
start_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||
end_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||
start_widget = view.mapFromScene(start_scene)
|
||||
end_widget = view.mapFromScene(end_scene)
|
||||
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_widget)
|
||||
qtbot.mouseMove(view.viewport(), pos=end_widget)
|
||||
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_widget)
|
||||
qtbot.wait(100)
|
||||
|
||||
rois = image_widget.roi_controller.rois
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
|
||||
|
||||
assert len(rois) == 1
|
||||
assert isinstance(rois[0], RectangularROI)
|
||||
assert rois[0].line_color == "#00BCD4"
|
||||
|
||||
# Now draw a circle; rectangle should be removed automatically
|
||||
rect_action.setChecked(False)
|
||||
circle_action = compact_roi_tree.toolbar.components.get_action("roi_circle").action
|
||||
circle_action.setChecked(True)
|
||||
|
||||
start_pos = QPointF(20, 20)
|
||||
end_pos = QPointF(40, 40)
|
||||
start_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||
end_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||
start_widget = view.mapFromScene(start_scene)
|
||||
end_widget = view.mapFromScene(end_scene)
|
||||
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_widget)
|
||||
qtbot.mouseMove(view.viewport(), pos=end_widget)
|
||||
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_widget)
|
||||
qtbot.wait(100)
|
||||
|
||||
rois = image_widget.roi_controller.rois
|
||||
assert len(rois) == 1
|
||||
assert isinstance(rois[0], CircularROI)
|
||||
assert rois[0].line_color == "#00BCD4"
|
||||
|
||||
|
||||
def test_compact_draw_mode_toggle(compact_roi_tree):
|
||||
# Initially no draw mode
|
||||
assert compact_roi_tree._roi_draw_mode is None
|
||||
|
||||
rect_action = compact_roi_tree.toolbar.components.get_action("roi_rectangle").action
|
||||
circle_action = compact_roi_tree.toolbar.components.get_action("roi_circle").action
|
||||
|
||||
# Toggle rect on
|
||||
rect_action.toggle()
|
||||
assert compact_roi_tree._roi_draw_mode == "rect"
|
||||
assert rect_action.isChecked()
|
||||
assert not circle_action.isChecked()
|
||||
|
||||
# Toggle circle on; rect should toggle off
|
||||
circle_action.toggle()
|
||||
assert compact_roi_tree._roi_draw_mode == "circle"
|
||||
assert circle_action.isChecked()
|
||||
assert not rect_action.isChecked()
|
||||
|
||||
# Toggle circle off → none
|
||||
circle_action.toggle()
|
||||
assert compact_roi_tree._roi_draw_mode is None
|
||||
assert not rect_action.isChecked()
|
||||
assert not circle_action.isChecked()
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
|
||||
|
||||
# For rectangular ROI: pixel bounding box equals coordinate bbox
|
||||
if isinstance(roi, RectangularROI):
|
||||
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
|
||||
(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]
|
||||
|
||||
@@ -191,51 +191,10 @@ def test_bec_weblinks(monkeypatch):
|
||||
assert opened_urls == [
|
||||
"https://beamline-experiment-control.readthedocs.io/en/latest/",
|
||||
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
|
||||
"https://gitlab.psi.ch/groups/bec/-/issues/",
|
||||
"https://github.com/bec-project/bec_widgets/issues",
|
||||
]
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for scan‑progress bar animations
|
||||
|
||||
|
||||
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
|
||||
"""
|
||||
_show_scan_progress_bar should animate the container's maximumWidth
|
||||
from 0 to the configured target width.
|
||||
"""
|
||||
container = bec_main_window._scan_progress_bar_with_separator
|
||||
|
||||
# Pre‑condition: collapsed
|
||||
assert container.maximumWidth() == 0
|
||||
|
||||
bec_main_window._show_scan_progress_bar()
|
||||
|
||||
target = bec_main_window._scan_progress_bar_target_width
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
|
||||
|
||||
assert container.maximumWidth() == target
|
||||
|
||||
|
||||
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
|
||||
"""
|
||||
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
|
||||
"""
|
||||
container = bec_main_window._scan_progress_bar_with_separator
|
||||
|
||||
# First expand it
|
||||
bec_main_window._show_scan_progress_bar()
|
||||
target = bec_main_window._scan_progress_bar_target_width
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
|
||||
|
||||
# Trigger hide animation
|
||||
bec_main_window._animate_hide_scan_progress_bar()
|
||||
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
|
||||
|
||||
assert container.maximumWidth() == 0
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for hover widget and tooltip behaviour
|
||||
|
||||
|
||||
372
tests/unit_tests/test_pdf_viewer.py
Normal file
372
tests/unit_tests/test_pdf_viewer.py
Normal file
@@ -0,0 +1,372 @@
|
||||
import pytest
|
||||
from qtpy.QtPdf import QPdfDocument
|
||||
from qtpy.QtPdfWidgets import QPdfView
|
||||
|
||||
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_viewer_widget(qtbot, mocked_client):
|
||||
"""Create a PDF viewer widget for testing."""
|
||||
widget = PdfViewerWidget(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
widget.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_pdf_file(tmpdir):
|
||||
"""Create a minimal 3-page PDF file for testing."""
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (Page 1) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (Page 2) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
7 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R >>
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (Page 3) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
9 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000010 00000 n
|
||||
0000000060 00000 n
|
||||
0000000125 00000 n
|
||||
0000000205 00000 n
|
||||
0000000282 00000 n
|
||||
0000000362 00000 n
|
||||
0000000439 00000 n
|
||||
0000000519 00000 n
|
||||
0000000596 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R >>
|
||||
startxref
|
||||
675
|
||||
%%EOF
|
||||
"""
|
||||
|
||||
pdf_path = tmpdir.join("test_3page.pdf")
|
||||
pdf_path.write_binary(pdf_content)
|
||||
return str(pdf_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_pdf_file_2(tmpdir):
|
||||
"""Create a second minimal temporary PDF file for testing."""
|
||||
# Create a minimal PDF content for testing
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/MediaBox [0 0 612 792]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
/Contents 4 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 44
|
||||
>>stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
100 700 Td
|
||||
(Second Test PDF) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000307 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
398
|
||||
%%EOF"""
|
||||
# Create temporary PDF file using tmpdir
|
||||
pdf_file = tmpdir.join("test2.pdf")
|
||||
pdf_file.write_binary(pdf_content)
|
||||
return str(pdf_file)
|
||||
|
||||
|
||||
def test_initialization(pdf_viewer_widget: PdfViewerWidget):
|
||||
"""Test that the widget initializes correctly."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Check basic widget setup
|
||||
assert widget is not None
|
||||
assert hasattr(widget, "pdf_view")
|
||||
assert hasattr(widget, "toolbar")
|
||||
assert hasattr(widget, "_pdf_document")
|
||||
|
||||
# Check initial state
|
||||
assert widget._current_file_path is None
|
||||
assert widget._page_spacing == 5
|
||||
assert widget._side_margins == 10
|
||||
|
||||
# Check PDF view setup
|
||||
assert isinstance(widget.pdf_view, QPdfView)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
|
||||
|
||||
# Check PDF document setup
|
||||
assert isinstance(widget._pdf_document, QPdfDocument)
|
||||
|
||||
|
||||
def test_toolbar_setup(pdf_viewer_widget: PdfViewerWidget):
|
||||
"""Test that toolbar is set up with all expected actions."""
|
||||
widget = pdf_viewer_widget
|
||||
toolbar = widget.toolbar
|
||||
|
||||
# Check that all expected actions exist
|
||||
expected_actions = [
|
||||
"open_file",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"fit_width",
|
||||
"fit_page",
|
||||
"reset_zoom",
|
||||
"continuous_scroll",
|
||||
"prev_page",
|
||||
"next_page",
|
||||
"page_jump",
|
||||
]
|
||||
|
||||
for action_name in expected_actions:
|
||||
assert toolbar.components.exists(action_name), f"Action {action_name} not found"
|
||||
|
||||
|
||||
def test_load_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file, temp_pdf_file_2):
|
||||
"""Test loading a PDF file into the viewer."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
qtbot.wait(100) # Wait for loading
|
||||
|
||||
# Check that the document is loaded
|
||||
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
|
||||
assert widget._pdf_document.pageCount() > 0
|
||||
assert widget._current_file_path == temp_pdf_file
|
||||
|
||||
# Load a second PDF file to test reloading
|
||||
widget.load_pdf(temp_pdf_file_2)
|
||||
qtbot.wait(100) # Wait for loading
|
||||
|
||||
# Check that the new document is loaded
|
||||
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
|
||||
assert widget._pdf_document.pageCount() > 0
|
||||
assert widget._current_file_path == temp_pdf_file_2
|
||||
|
||||
assert widget.current_file_path == temp_pdf_file_2
|
||||
|
||||
widget.current_file_path = temp_pdf_file
|
||||
qtbot.wait(100) # Wait for loading
|
||||
assert widget.current_file_path == temp_pdf_file
|
||||
|
||||
|
||||
def test_load_invalid_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, tmpdir):
|
||||
"""Test loading an invalid PDF file into the viewer."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Try to open a non-existent file
|
||||
invalid_pdf_file = tmpdir.join("non_existent.pdf")
|
||||
|
||||
# Attempt to load the invalid PDF file
|
||||
with pytest.raises(FileNotFoundError):
|
||||
widget.load_pdf(str(invalid_pdf_file), _override_slot_params={"raise_error": True})
|
||||
|
||||
|
||||
def test_page_navigation(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test page navigation functionality."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Check initial page
|
||||
assert widget.current_page == 1
|
||||
total_pages = widget._pdf_document.pageCount()
|
||||
assert total_pages >= 1
|
||||
|
||||
# Navigate to next page
|
||||
widget.next_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 2
|
||||
|
||||
# Navigate to previous page
|
||||
widget.previous_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 1
|
||||
|
||||
# Jump to last page
|
||||
widget.jump_to_page(total_pages)
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == total_pages
|
||||
|
||||
widget.jump_to_page(1)
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 1
|
||||
|
||||
widget.jump_to_page(2)
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 2
|
||||
|
||||
widget.go_to_last_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == total_pages
|
||||
|
||||
widget.go_to_first_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 1
|
||||
|
||||
widget.page_input.setText(str(total_pages + 10))
|
||||
widget.page_input.returnPressed.emit()
|
||||
qtbot.wait(100)
|
||||
assert widget.current_page == total_pages
|
||||
|
||||
|
||||
def test_zoom_controls(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test zoom in, zoom out, fit width, fit page, and reset zoom functionality."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Initial zoom mode should be FitToWidth
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
|
||||
|
||||
# Zoom in
|
||||
initial_zoom = widget.pdf_view.zoomFactor()
|
||||
widget.zoom_in()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomFactor() > initial_zoom
|
||||
|
||||
# Zoom out
|
||||
zoom_after_in = widget.pdf_view.zoomFactor()
|
||||
widget.zoom_out()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomFactor() < zoom_after_in
|
||||
|
||||
# Fit to page
|
||||
widget.fit_to_page()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitInView
|
||||
|
||||
# Fit to width
|
||||
widget.fit_to_width()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
|
||||
|
||||
# Reset zoom
|
||||
widget.reset_zoom()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.Custom
|
||||
|
||||
|
||||
def test_page_spacing_and_margins(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test setting page spacing and side margins."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Set and verify page spacing
|
||||
widget.page_spacing = 20
|
||||
assert widget.page_spacing == 20
|
||||
|
||||
# Set and verify side margins
|
||||
widget.side_margins = 30
|
||||
assert widget.side_margins == 30
|
||||
|
||||
|
||||
def test_toggle_continuous_scroll(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test toggling continuous scroll mode."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Initial mode should be single page
|
||||
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
|
||||
|
||||
# Toggle to continuous scroll
|
||||
widget.toggle_continuous_scroll(True)
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.pageMode() == QPdfView.PageMode.MultiPage
|
||||
|
||||
# Toggle back to single page
|
||||
widget.toggle_continuous_scroll(False)
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
|
||||
|
||||
widget.jump_to_page(2)
|
||||
qtbot.wait(100)
|
||||
assert widget.current_page == 2
|
||||
@@ -50,7 +50,7 @@ def positioner_box(qtbot, mocked_client):
|
||||
def test_positioner_box(positioner_box):
|
||||
"""Test init of positioner box"""
|
||||
assert positioner_box.device == "samx"
|
||||
data = positioner_box.dev["samx"].read()
|
||||
data = positioner_box.dev["samx"].read(cached=True)
|
||||
# Avoid check for Positioner class from BEC in _init_device
|
||||
|
||||
setpoint_text = positioner_box.ui.setpoint.text()
|
||||
|
||||
@@ -29,8 +29,8 @@ def test_positioner_box_2d(positioner_box_2d):
|
||||
"""Test init of 2D positioner box"""
|
||||
assert positioner_box_2d.device_hor == "samx"
|
||||
assert positioner_box_2d.device_ver == "samy"
|
||||
data_hor = positioner_box_2d.dev["samx"].read()
|
||||
data_ver = positioner_box_2d.dev["samy"].read()
|
||||
data_hor = positioner_box_2d.dev["samx"].read(cached=True)
|
||||
data_ver = positioner_box_2d.dev["samy"].read(cached=True)
|
||||
# Avoid check for Positioner class from BEC in _init_device
|
||||
|
||||
setpoint_hor_text = positioner_box_2d.ui.setpoint_hor.text()
|
||||
@@ -80,3 +80,60 @@ def test_positioner_box_setpoint_changes(positioner_box_2d: PositionerBox2D):
|
||||
positioner_box_2d.ui.setpoint_ver.setText("100")
|
||||
positioner_box_2d.on_setpoint_change_ver()
|
||||
mock_move.assert_called_once_with(100, relative=False)
|
||||
|
||||
|
||||
def _hor_buttons(widget: PositionerBox2D):
|
||||
return [
|
||||
widget.ui.tweak_increase_hor,
|
||||
widget.ui.tweak_decrease_hor,
|
||||
widget.ui.step_increase_hor,
|
||||
widget.ui.step_decrease_hor,
|
||||
]
|
||||
|
||||
|
||||
def _ver_buttons(widget: PositionerBox2D):
|
||||
return [
|
||||
widget.ui.tweak_increase_ver,
|
||||
widget.ui.tweak_decrease_ver,
|
||||
widget.ui.step_increase_ver,
|
||||
widget.ui.step_decrease_ver,
|
||||
]
|
||||
|
||||
|
||||
def test_controls_default_enabled(positioner_box_2d: PositionerBox2D):
|
||||
"""By default both axes controls are enabled and UI reflects it."""
|
||||
assert positioner_box_2d.enable_controls_hor is True
|
||||
assert positioner_box_2d.enable_controls_ver is True
|
||||
assert all(w.isEnabled() for w in _hor_buttons(positioner_box_2d))
|
||||
assert all(w.isEnabled() for w in _ver_buttons(positioner_box_2d))
|
||||
|
||||
|
||||
def test_disable_enable_controls_and_persist_across_device_change(
|
||||
positioner_box_2d: PositionerBox2D, qtbot
|
||||
):
|
||||
"""Disabling an axis should disable its buttons and remain disabled after device (re)binding."""
|
||||
# Disable horizontal and verify UI
|
||||
positioner_box_2d.enable_controls_hor = False
|
||||
assert positioner_box_2d.enable_controls_hor is False
|
||||
assert all(not w.isEnabled() for w in _hor_buttons(positioner_box_2d))
|
||||
|
||||
# Simulate a horizontal device change; state must persist after queued re-apply
|
||||
positioner_box_2d.on_device_change_hor("samx", "samx")
|
||||
qtbot.waitUntil(lambda: all(not w.isEnabled() for w in _hor_buttons(positioner_box_2d)))
|
||||
|
||||
# Re-enable and verify UI
|
||||
positioner_box_2d.enable_controls_hor = True
|
||||
qtbot.waitUntil(lambda: all(w.isEnabled() for w in _hor_buttons(positioner_box_2d)))
|
||||
|
||||
# Disable vertical and verify UI
|
||||
positioner_box_2d.enable_controls_ver = False
|
||||
assert positioner_box_2d.enable_controls_ver is False
|
||||
assert all(not w.isEnabled() for w in _ver_buttons(positioner_box_2d))
|
||||
|
||||
# Simulate a vertical device change; state must persist after queued re-apply
|
||||
positioner_box_2d.on_device_change_ver("samy", "samy")
|
||||
qtbot.waitUntil(lambda: all(not w.isEnabled() for w in _ver_buttons(positioner_box_2d)))
|
||||
|
||||
# Re-enable and verify UI
|
||||
positioner_box_2d.enable_controls_ver = True
|
||||
qtbot.waitUntil(lambda: all(w.isEnabled() for w in _ver_buttons(positioner_box_2d)))
|
||||
|
||||
@@ -21,7 +21,6 @@ def test_multiple_extension_registration():
|
||||
"""
|
||||
Test that multiple extension registrations do not cause issues.
|
||||
"""
|
||||
assert serialization.module_is_registered("bec_widgets.utils.serialization")
|
||||
assert msgpack.is_registered(QPointF)
|
||||
serialization.register_serializer_extension()
|
||||
assert serialization.module_is_registered("bec_widgets.utils.serialization")
|
||||
assert len(msgpack._encoder) == len(set(msgpack._encoder))
|
||||
assert msgpack.is_registered(QPointF)
|
||||
|
||||
@@ -10,22 +10,19 @@ import pyqtgraph as pg
|
||||
import pytest
|
||||
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QSpinBox,
|
||||
)
|
||||
from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox
|
||||
|
||||
from bec_widgets.widgets.plots.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
)
|
||||
from tests.unit_tests.client_mocks import (
|
||||
DummyData,
|
||||
create_dummy_scan_item,
|
||||
dap_plugin_message,
|
||||
inject_scan_history,
|
||||
mocked_client,
|
||||
mocked_client_with_dap,
|
||||
)
|
||||
@@ -482,6 +479,43 @@ def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Ensure that custom curves can also serve as parents for DAP fits.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
x = np.linspace(-1, 1, 50)
|
||||
y = np.sin(x)
|
||||
custom_curve = wf.plot(x=x, y=y, label="custom-curve")
|
||||
|
||||
dap_curve = wf.add_dap_curve(device_label=custom_curve.name(), dap_name="GaussianModel")
|
||||
assert dap_curve.config.source == "dap"
|
||||
assert dap_curve.config.parent_label == custom_curve.name()
|
||||
assert dap_curve.config.signal.name == custom_curve.name()
|
||||
assert dap_curve.config.signal.entry == "custom"
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
with qtbot.waitSignal(wf.request_dap_update):
|
||||
c.set_data([7, 8, 9], [10, 11, 12])
|
||||
|
||||
|
||||
def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Supplying the `dap` kwarg when plotting custom data should auto-create the fit curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
curve = wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-inline", dap="GaussianModel")
|
||||
|
||||
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
||||
assert dap_curve is not None
|
||||
assert dap_curve.config.parent_label == curve.name()
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
|
||||
@@ -841,6 +875,33 @@ def test_show_dap_summary_popup(qtbot, mocked_client):
|
||||
assert fit_action.isChecked() is False
|
||||
|
||||
|
||||
def test_show_scan_history_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Test that show_scan_history_popup displays the scan history browser dialog
|
||||
and toggles the toolbar action correctly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
scan_action = wf.toolbar.components.get_action("scan_history").action
|
||||
# Initially unchecked and no dialog
|
||||
assert not scan_action.isChecked()
|
||||
assert wf.scan_history_dialog is None
|
||||
|
||||
# Show the popup
|
||||
wf.show_scan_history_popup()
|
||||
# Dialog should exist and be visible, action checked
|
||||
assert wf.scan_history_dialog is not None
|
||||
assert wf.scan_history_dialog.isVisible()
|
||||
assert scan_action.isChecked()
|
||||
# The embedded widget should be the correct type
|
||||
assert isinstance(wf.scan_history_widget, ScanHistoryBrowser)
|
||||
|
||||
# Close the dialog (triggers _scan_history_closed)
|
||||
wf.scan_history_dialog.close()
|
||||
# Dialog reference should be cleared and action unchecked
|
||||
assert wf.scan_history_dialog is None
|
||||
assert not scan_action.isChecked()
|
||||
|
||||
|
||||
#####################################################
|
||||
# The following tests are for the async dataset guard
|
||||
#####################################################
|
||||
@@ -1063,3 +1124,187 @@ def test_dialog_reject_real_interaction(qtbot, mocked_client):
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
# Limit remains unchanged
|
||||
assert wf.max_dataset_size_mb == 1
|
||||
|
||||
|
||||
def test_update_with_scan_history_by_index(qtbot, mocked_client, scan_history_factory):
|
||||
"""
|
||||
Test that update_with_scan_history by index loads the correct historical scan.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
|
||||
|
||||
assert len(wf.client.history._scan_ids) == 2, "Expected two history scans"
|
||||
|
||||
# Do history curve plotting
|
||||
wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="hist1")
|
||||
wf.plot(y_name="bpm4i", scan_number=2)
|
||||
|
||||
assert len(wf.plot_item.curves) == 2, "Expected two curves for history scans"
|
||||
c1, c2 = wf.plot_item.curves
|
||||
# First curve should be for hist1, second for hist2
|
||||
assert c1.config.signal.name == "bpm4i"
|
||||
assert c1.config.signal.entry == "bpm4i"
|
||||
assert c1.config.scan_id == "hist1"
|
||||
assert c1.config.scan_number == 1
|
||||
assert c1.name() == "bpm4i-bpm4i-scan-1"
|
||||
|
||||
assert c2.config.signal.name == "bpm4i"
|
||||
assert c2.config.signal.entry == "bpm4i"
|
||||
assert c2.config.scan_id == "hist2"
|
||||
assert c2.config.scan_number == 2
|
||||
assert c2.name() == "bpm4i-bpm4i-scan-2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["auto", "timestamp", "index", "samx"])
|
||||
def test_history_curve_x_modes_pre_plot(qtbot, mocked_client, scan_history_factory, mode):
|
||||
"""
|
||||
Test that history curves respect x_mode when set before plotting.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
|
||||
wf.x_mode = mode
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="hist1")
|
||||
assert c.config.current_x_mode == mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["auto", "timestamp", "index", "samx"])
|
||||
def test_history_curve_x_modes_post_plot(qtbot, mocked_client, scan_history_factory, mode):
|
||||
"""
|
||||
Test that changing x_mode after plotting history curves updates the curve on refresh.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="hist1")
|
||||
# Change x_mode after plotting
|
||||
wf.x_mode = mode
|
||||
# Refresh history curves
|
||||
wf._refresh_history_curves()
|
||||
assert c.config.current_x_mode == mode
|
||||
|
||||
|
||||
def test_history_curve_incompatible_x_mode_hides_curve(qtbot, mocked_client, scan_history_factory):
|
||||
"""
|
||||
Test that setting an x_mode not present in stored data hides the history curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "nonexistent_device"
|
||||
# Inject history scan for this test
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_bad", 1))
|
||||
# Plot history curve
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
|
||||
# Curve should be hidden due to incompatible x_mode
|
||||
assert not c.isVisible()
|
||||
|
||||
|
||||
def test_fetch_history_data_no_stored_data_raises(
|
||||
qtbot, mocked_client, monkeypatch, suppress_message_box
|
||||
):
|
||||
"""
|
||||
Test that fetching history data when stored_data_info is missing raises ValueError.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
# Create a dummy scan_item lacking stored_data_info
|
||||
dummy_scan = SimpleNamespace(
|
||||
_msg=SimpleNamespace(stored_data_info=None),
|
||||
devices={},
|
||||
metadata={"bec": {"scan_id": "dummy", "scan_number": 1, "scan_report_devices": []}},
|
||||
)
|
||||
# Force get_history_scan_item to return our dummy
|
||||
monkeypatch.setattr(wf, "get_history_scan_item", lambda scan_id, scan_index: dummy_scan)
|
||||
# Attempt to plot history curve should be suppressed by SafeSlot and return None
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="dummy", scan_number=1)
|
||||
assert c is None
|
||||
assert len(wf.curves) == 0
|
||||
|
||||
|
||||
def test_history_curve_device_missing_returns_none(qtbot, mocked_client, scan_history_factory):
|
||||
"""
|
||||
If the y-device is not in stored_data_info, plot should return None.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "index"
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_dev_missing", 1))
|
||||
c = wf.plot(y_name="non-existing", y_entry="non-existing", scan_id=history_msg.scan_id)
|
||||
assert c is None
|
||||
|
||||
|
||||
def test_history_curve_custom_shape_mismatch_hides_curve(
|
||||
qtbot, mocked_client, scan_history_factory
|
||||
):
|
||||
"""
|
||||
For custom x-mode, if x and y shapes mismatch, curve should be hidden.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "async_device"
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_custom_shape", 1))
|
||||
# Force shape mismatch for x-data
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
|
||||
assert c is not None
|
||||
assert not c.isVisible()
|
||||
|
||||
|
||||
def test_history_curve_index_mode_plots_curve(qtbot, mocked_client, scan_history_factory):
|
||||
"""
|
||||
Test that setting x_mode to 'index' plots and shows the history curve correctly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "index"
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_index", 1))
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
|
||||
assert c is not None
|
||||
assert c.isVisible()
|
||||
assert c.config.current_x_mode == "index"
|
||||
|
||||
|
||||
def test_history_curve_timestamp_mode_plots_curve(qtbot, mocked_client, scan_history_factory):
|
||||
"""
|
||||
Test that setting x_mode to 'timestamp' plots and shows the history curve correctly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "timestamp"
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_time", 1))
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
|
||||
assert c is not None
|
||||
assert c.isVisible()
|
||||
assert c.config.current_x_mode == "timestamp"
|
||||
|
||||
|
||||
def test_history_curve_auto_valid_uses_first_report_device(
|
||||
qtbot, mocked_client, scan_history_factory
|
||||
):
|
||||
"""
|
||||
Test that 'auto' x_mode uses the first available report device and shows the curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "auto"
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_auto_valid", 1))
|
||||
# Plot history curve
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
|
||||
assert c is not None
|
||||
assert c.isVisible()
|
||||
# Should have fallen back to the first scan_report_device
|
||||
assert c.config.current_x_mode == "auto"
|
||||
|
||||
|
||||
def test_history_curve_file_not_found_returns_none(qtbot, mocked_client, scan_history_factory):
|
||||
"""
|
||||
If the history file path does not exist, plot should return None.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "index"
|
||||
# Inject a valid history message then corrupt its file_path
|
||||
[history_msg] = inject_scan_history(wf, scan_history_factory, ("bad_file", 1))
|
||||
history_msg.file_path = "/nonexistent/path.h5"
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id=history_msg.scan_id)
|
||||
assert c is None
|
||||
|
||||
|
||||
def test_history_curve_scan_not_found_returns_none(qtbot, mocked_client):
|
||||
"""
|
||||
If the requested scan_id is not in history, plot should return None.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.x_mode = "index"
|
||||
# No history scans injected for this widget
|
||||
c = wf.plot(y_name="bpm4i", y_entry="bpm4i", scan_id="unknown_scan")
|
||||
assert c is None
|
||||
Reference in New Issue
Block a user