mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 18:20:55 +02:00
Compare commits
73 Commits
theme_comp
...
v2.45.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
4f075151d5 | ||
| 0a24ac2c40 | |||
| 3a2ec9f1b7 | |||
| 4dc4ede1d2 | |||
| 556832fd48 | |||
| 72b6f74252 | |||
| b703b37bbd | |||
| 18ef35f22a | |||
| fe67a4f325 |
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
|
||||
|
||||
12
.github/workflows/stale-issues.yml
vendored
12
.github/workflows/stale-issues.yml
vendored
@@ -2,14 +2,18 @@ name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '00 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
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.
|
||||
|
||||
288
CHANGELOG.md
288
CHANGELOG.md
@@ -1,6 +1,294 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
- **crosshair**: Ignore fetching data and markers from invisible items
|
||||
([`72b6f74`](https://github.com/bec-project/bec_widgets/commit/72b6f74252e1f36339945c549049b166cccf3561))
|
||||
|
||||
- **plot_base**: Crosshair items are excluded from visible curves and from auto_range
|
||||
([`4dc4ede`](https://github.com/bec-project/bec_widgets/commit/4dc4ede1d251d081e5bcf3d37fcc784982c9258e))
|
||||
|
||||
- **plot_base**: Visible items injected into plot item
|
||||
([`b703b37`](https://github.com/bec-project/bec_widgets/commit/b703b37bbdbf97182b58ac4c69c1384fa78d0c12))
|
||||
|
||||
- **waveform**: Changing curve visibility refresh markers
|
||||
([`556832f`](https://github.com/bec-project/bec_widgets/commit/556832fd48bcb16b95df8cf91417d7045bbca2a3))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Fix stale issues job permissions; add workflow dispatch option
|
||||
([`fe67a4f`](https://github.com/bec-project/bec_widgets/commit/fe67a4f325cbd41f13102e5698d86ed9e90b048e))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Move to autoapi
|
||||
([`18ef35f`](https://github.com/bec-project/bec_widgets/commit/18ef35f22a1b7496b13f833e63a4f3875e1497e3))
|
||||
|
||||
### Testing
|
||||
|
||||
- **crosshair**: Visibility test added with plotbase fixture
|
||||
([`3a2ec9f`](https://github.com/bec-project/bec_widgets/commit/3a2ec9f1b74c4bb5f239940b874576a877ce45c0))
|
||||
|
||||
|
||||
## v2.38.1 (2025-08-22)
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -209,8 +209,11 @@ class Crosshair(QObject):
|
||||
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
|
||||
# Focus on the highlighted curve only
|
||||
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
|
||||
else:
|
||||
# Handle all curves
|
||||
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
|
||||
# Handle visible items in the plot item
|
||||
self.items = self.plot_item.visible_items()
|
||||
else: # Non PlotBase case
|
||||
# Handle all items
|
||||
self.items = self.plot_item.items
|
||||
|
||||
# Create or update markers
|
||||
|
||||
@@ -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
|
||||
@@ -350,11 +360,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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
@@ -109,6 +153,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
|
||||
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
|
||||
self.plot_widget.addItem(self.plot_item)
|
||||
self.plot_item.visible_items = lambda: self.visible_items
|
||||
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
|
||||
|
||||
# PlotItem Addons
|
||||
@@ -830,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:
|
||||
"""
|
||||
@@ -893,15 +972,20 @@ class PlotBase(BECWidget, QWidget):
|
||||
return
|
||||
self._apply_autorange_only_visible_curves()
|
||||
|
||||
def _fetch_visible_curves(self):
|
||||
"""
|
||||
Fetch all visible curves from the plot item.
|
||||
"""
|
||||
visible_curves = []
|
||||
for curve in self.plot_item.curves:
|
||||
if curve.isVisible():
|
||||
visible_curves.append(curve)
|
||||
return visible_curves
|
||||
@property
|
||||
def visible_items(self):
|
||||
crosshair_items = []
|
||||
if self.crosshair:
|
||||
crosshair_items = [
|
||||
self.crosshair.v_line,
|
||||
self.crosshair.h_line,
|
||||
self.crosshair.coord_label,
|
||||
]
|
||||
return [
|
||||
item
|
||||
for item in self.plot_item.items
|
||||
if item.isVisible() and item not in crosshair_items
|
||||
]
|
||||
|
||||
def _apply_autorange_only_visible_curves(self):
|
||||
"""
|
||||
@@ -910,8 +994,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
Args:
|
||||
curves (list): List of curves to apply autorange to.
|
||||
"""
|
||||
visible_curves = self._fetch_visible_curves()
|
||||
self.plot_item.autoRange(items=visible_curves if visible_curves else None)
|
||||
visible_items = self.visible_items
|
||||
|
||||
self.plot_item.autoRange(items=visible_items if visible_items else None)
|
||||
|
||||
@SafeProperty(int, doc="The font size of the legend font.")
|
||||
def legend_label_size(self) -> int:
|
||||
|
||||
@@ -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,7 +204,7 @@ 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)
|
||||
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,22 +976,182 @@ 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.
|
||||
"""
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.clear_markers()
|
||||
|
||||
def _generate_color_from_palette(self) -> str:
|
||||
"""
|
||||
Generate a color for the next new curve, based on the current number of curves.
|
||||
@@ -958,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):
|
||||
"""
|
||||
@@ -1115,12 +1371,13 @@ class Waveform(PlotBase):
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
self.auto_range(True)
|
||||
self.auto_range_x = True
|
||||
self.auto_range_y = True
|
||||
self.old_scan_id = self.scan_id
|
||||
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
|
||||
@@ -1198,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:
|
||||
@@ -1371,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:
|
||||
@@ -1606,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":
|
||||
@@ -1620,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":
|
||||
@@ -1632,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:
|
||||
@@ -1654,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
|
||||
|
||||
@@ -1756,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()
|
||||
|
||||
@@ -2029,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()
|
||||
|
||||
|
||||
@@ -2036,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)
|
||||
@@ -2048,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 = "__", ""
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
(api_reference)=
|
||||
# API Reference
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:toctree: _autosummary
|
||||
:template: custom-module-template.rst
|
||||
:recursive:
|
||||
This page contains the auto-generated API documentation for all modules, classes, and functions in the BEC Widgets package.
|
||||
|
||||
bec_widgets
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
:caption: API Documentation
|
||||
|
||||
../autoapi/bec_widgets/index
|
||||
```
|
||||
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 |
42
docs/conf.py
42
docs/conf.py
@@ -32,16 +32,15 @@ def get_version():
|
||||
release = get_version()
|
||||
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.autosummary",
|
||||
# "sphinx.ext.coverage",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.napoleon",
|
||||
"sphinx_toolbox.collapse",
|
||||
"sphinx_copybutton",
|
||||
"myst_parser",
|
||||
"sphinx_design",
|
||||
"sphinx_inline_tabs",
|
||||
"autoapi.extension",
|
||||
"sphinx.ext.viewcode",
|
||||
]
|
||||
|
||||
myst_enable_extensions = [
|
||||
@@ -60,7 +59,15 @@ myst_enable_extensions = [
|
||||
"tasklist",
|
||||
]
|
||||
|
||||
autosummary_generate = True # Turn on sphinx.ext.autosummary
|
||||
# AutoAPI configuration
|
||||
autoapi_dirs = ["../bec_widgets"]
|
||||
autoapi_type = "python"
|
||||
autoapi_generate_api_docs = True
|
||||
autoapi_add_toctree_entry = False # We'll control the toctree manually
|
||||
autoapi_keep_files = False
|
||||
autoapi_python_class_content = "both" # Include both class docstring and __init__
|
||||
autoapi_member_order = "groupwise"
|
||||
|
||||
add_module_names = False # Remove namespaces from class/method signatures
|
||||
autodoc_inherit_docstrings = True # If no docstring, inherit from base class
|
||||
set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints
|
||||
@@ -80,3 +87,30 @@ html_theme = "pydata_sphinx_theme"
|
||||
html_static_path = ["_static"]
|
||||
html_css_files = ["custom.css"]
|
||||
html_logo = "../bec_widgets/assets/app_icons/bec_widgets_icon.png"
|
||||
|
||||
|
||||
def skip_submodules(app, what, name, obj, skip, options):
|
||||
if what == "module":
|
||||
if not name.startswith("bec_widgets"):
|
||||
skip = True
|
||||
# print(f"Checking module: {name}")
|
||||
if "bec_widgets.widgets" in name:
|
||||
widget = name.split(".")[-2]
|
||||
submodule = name.split(".")[-1]
|
||||
if submodule in [f"register_{widget}", f"{widget}_plugin"]:
|
||||
# print(f"Skipping submodule: {name}")
|
||||
skip = True
|
||||
elif what in ["data", "attribute"]:
|
||||
obj_name = name.split(".")[-1]
|
||||
if obj_name.startswith("_") or obj_name in ["__all__", "logger", "bec_logger", "app"]:
|
||||
skip = True
|
||||
|
||||
elif what == "class":
|
||||
class_name = name.split(".")[-1]
|
||||
if class_name.startswith("Demo"):
|
||||
skip = True
|
||||
return skip
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.connect("autoapi-skip-member", skip_submodules)
|
||||
|
||||
@@ -10,5 +10,5 @@ We offer up to three different options for composing larger GUIs from these modu
|
||||
## Client-Server Architecture
|
||||
|
||||
BEC Widgets is built on top of the [BEC](https://bec.readthedocs.io/en/latest/) package, which provides the backend for beamline experiment control. BEC Widgets is a client of BEC, meaning it can interact with the backend through a client-server architecture. To make full usage of the available features of BEC, we recommend to check the documentation about [data access](https://bec.readthedocs.io/en/latest/developer/data_access/data_access.html) in which the messaging and event system of BEC is described.
|
||||
In the context of BEC Widgets, the [`BECDispatcher`](/api_reference/_autosummary/bec_widgets.utils.bec_dispatcher.BECDispatcher) connects to this messaging and event system, allowing you to link your Qt [`Slots`](https://www.pythonguis.com/tutorials/pyside6-signals-slots-events/) to messages and event received from BEC.
|
||||
In the context of BEC Widgets, the {py:class}`~bec_widgets.utils.bec_dispatcher.BECDispatcher` connects to this messaging and event system, allowing you to link your Qt [`Slots`](https://www.pythonguis.com/tutorials/pyside6-signals-slots-events/) to messages and event received from BEC.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ sphinx-copybutton
|
||||
sphinx-inline-tabs
|
||||
myst-parser
|
||||
sphinx-design
|
||||
sphinx-autoapi
|
||||
tomli
|
||||
@@ -1,11 +1,10 @@
|
||||
(user.api_reference)=
|
||||
# User API Reference
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:toctree: _autosummary
|
||||
:template: custom-module-template.rst
|
||||
This section contains the API documentation for the main user-facing modules and classes.
|
||||
|
||||
bec_widgets.cli.client
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
|
||||
../../autoapi/bec_widgets/cli/index
|
||||
```
|
||||
@@ -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**
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
In order to use BEC Widgets as a plotting tool for BEC, it needs to be [installed](#user.installation) in the same Python environment as the BEC IPython client (please refer to the [BEC documentation](https://bec.readthedocs.io/en/latest/user/command_line_interface.html#start-up) for more details). Upon startup, the client will automatically launch a GUI and store it as a `gui` object in the client. The GUI backend will also be automatically connect to the BEC server, giving access to all information on the server and allowing the user to visualize the data in real-time.
|
||||
|
||||
## BECGuiClient
|
||||
The `gui` object is the main entry point for interacting with the BEC Widgets framework. It is an instance of the [`BECGuiClient`](/api_reference/_autosummary/bec_widgets.cli.client.BECGuiClient) class, which provides methods to create and manage GUI components. Upon BEC startup, a default [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) instance named *bec* is automatically launched.
|
||||
The `gui` object is the main entry point for interacting with the BEC Widgets framework. It is an instance of the {py:class}`~bec_widgets.cli.client_utils.BECGuiClient` class, which provides methods to create and manage GUI components. Upon BEC startup, a default {py:class}`~bec_widgets.cli.client.BECDockArea` instance named *bec* is automatically launched.
|
||||
|
||||
A launcher interface is available via the top menu bar under New → Open Launcher. This opens a window where users can launch a new [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) instance, an [AutoUpdate](#user.auto_updates) instance, individual widgets or a custom *ui file* created with *BEC Designer*. Alternatively, users can launch a new [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) from the command line:
|
||||
A launcher interface is available via the top menu bar under New → Open Launcher. This opens a window where users can launch a new {py:class}`~bec_widgets.cli.client.BECDockArea` instance, an [AutoUpdate](#user.auto_updates) instance, individual widgets or a custom *ui file* created with *BEC Designer*. Alternatively, users can launch a new {py:class}`~bec_widgets.cli.client.BECDockArea` from the command line:
|
||||
|
||||
```python
|
||||
dock_area = gui.new() # launches a new BECDockArea instance
|
||||
@@ -19,7 +19,7 @@ If a name is provided, the new dock area will use that name. If the name already
|
||||
|
||||
|
||||
## BECDockArea
|
||||
The [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) is a versatile container for quickly building customized GUIs. It supports adding new widgets either through the CLI or directly via toolbar actions. Widgets must be added into [`BECDock`](/api_reference/_autosummary/bec_widgets.cli.client.BECDock) instances, which serve as the individual containers. These docks can be arranged freely, detached from the main window, and used as floating panels.
|
||||
The {py:class}`~bec_widgets.cli.client.BECDockArea` is a versatile container for quickly building customized GUIs. It supports adding new widgets either through the CLI or directly via toolbar actions. Widgets must be added into {py:class}`~bec_widgets.cli.client.BECDockArea` instances, which serve as the individual containers. These docks can be arranged freely, detached from the main window, and used as floating panels.
|
||||
|
||||
From the CLI, you can create new docks like this:
|
||||
|
||||
@@ -34,23 +34,23 @@ dock = gui.new().new()
|
||||
 -->
|
||||
|
||||
## Widgets
|
||||
Widgets are the building blocks of the BEC Widgets framework. They are the visual components that allow users to interact with the data and control the behavior of the application. Each dock can contain multiple widgets, albeit we recommend for most use cases a single widget per dock. BEC Widgets provides a set of core widgets (cf. [widgets](#user.widgets)). More widgets can be added by the users, and we invite you to explore the [developer documentation](developer.widgets) to learn how to create custom widgets.
|
||||
Widgets are the building blocks of the BEC Widgets framework. They are the visual components that allow users to interact with the data and control the behavior of the application. Each dock can contain multiple widgets, albeit we recommend for most use cases a single widget per dock. BEC Widgets provides a set of core widgets (cf. {ref}`user.widgets`). More widgets can be added by the users, and we invite you to explore the {ref}`developer.widgets` to learn how to create custom widgets.
|
||||
For the introduction given here, we will focus on the plotting widgets of BECWidgets.
|
||||
|
||||
<!-- We also provide two methods [`plot()`](/api_reference/_autosummary/bec_widgets.cli.client.BECFigure.rst#bec_widgets.cli.client.BECFigure.plot), [`image()`](/api_reference/_autosummary/bec_widgets.cli.client.BECFigure.rst#bec_widgets.cli.client.BECFigure.image) and [`motor_map()`](/api_reference/_autosummary/bec_widgets.cli.client.BECFigure.rst#bec_widgets.cli.client.BECFigure.motor_map) as shortcuts to add a plot, image or motor map to the BECFigure. -->
|
||||
|
||||
**Waveform Plot**
|
||||
|
||||
The [`WaveForm`](/api_reference/_autosummary/bec_widgets.cli.client.WaveForm) is a widget that can be used to visualize 1D waveform data, i.e. to plot data of a monitor against a motor position. The method [`plot()`](/api_reference/_autosummary/bec_widgets.cli.client.WaveForm.rst#bec_widgets.cli.client.WaveForm.plot) returns the plot object.
|
||||
The {py:class}`~bec_widgets.cli.client.Waveform` is a widget that can be used to visualize 1D waveform data, i.e. to plot data of a monitor against a motor position. The method {py:meth}`~bec_widgets.cli.client.Waveform.plot` returns the plot object.
|
||||
|
||||
```python
|
||||
plt = gui.new().new().new(gui.available_widgets.Waveform)
|
||||
plt.plot(x_name='samx', y_name='bpm4i')
|
||||
```
|
||||
Here, we create a new plot with a subscription to the devices `samx` and `bpm4i` and assign the plot to the object `plt`. We can now use this object to further customize the plot, e.g. changing the title ([`plt.title = 'my title' `](/api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst#bec_widgets.cli.client.Waveform.title)), axis labels ([`plt.x_label = 'my x label'`](/api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst#bec_widgets.cli.client.Waveform.x_label))
|
||||
<!-- or limits ([`set_x_lim()`](/api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst#bec_widgets.cli.client.Waveform.x_lim)). -->
|
||||
Here, we create a new plot with a subscription to the devices `samx` and `bpm4i` and assign the plot to the object `plt`. We can now use this object to further customize the plot, e.g. changing the title (`title`), axis labels (`x_label`)
|
||||
<!-- or limits (`x_lim`). -->
|
||||
|
||||
We invite you to explore the API of the WaveForm in the [documentation](user.widgets.waveform_1d) or directly in the command line.
|
||||
We invite you to explore the API of the WaveForm in the {ref}`user.widgets.waveform_1d` or directly in the command line.
|
||||
|
||||
To plot custom data, i.e. data that is not directly available through a scan in BEC, we can use the same method, but provide the data directly to the plot.
|
||||
|
||||
@@ -68,18 +68,18 @@ curve = plt.plot(x=[1,2,3,4], y=[1,4,9,16])
|
||||
|
||||
**Scatter Plot**
|
||||
|
||||
The [`WaveForm`](/api_reference/_autosummary/bec_widgets.cli.client.WaveForm) widget can also be used to visualize 2D scatter plots. More details on setting up the scatter plot are available in the widget documentation of the [scatter plot](user.widgets.scatter_2d).
|
||||
The {py:class}`~bec_widgets.cli.client.Waveform` widget can also be used to visualize 2D scatter plots. More details on setting up the scatter plot are available in the widget documentation of the {ref}`user.widgets.scatter_2d`.
|
||||
|
||||
**Motor Map**
|
||||
|
||||
The [`MotorMap`](/api_reference/_autosummary/bec_widgets.cli.client.MotorMap) widget can be used to visualize the position of motors. It's focused on tracking and visualizing the position of motors, crucial for precise alignment and movement tracking during scans. More details on setting up the motor map are available in the widget documentation of the [motor map](user.widgets.motor_map).
|
||||
The {py:class}`~bec_widgets.cli.client.MotorMap` widget can be used to visualize the position of motors. It's focused on tracking and visualizing the position of motors, crucial for precise alignment and movement tracking during scans. More details on setting up the motor map are available in the widget documentation of the {ref}`user.widgets.motor_map`.
|
||||
|
||||
**Image Plot**
|
||||
|
||||
The [`Image`](/api_reference/_autosummary/bec_widgets.cli.client.Image) widget can be used to visualize 2D image data for example a camera. More details on setting up the image plot are available in the widget documentation of the [image plot](user.widgets.image).
|
||||
The {py:class}`~bec_widgets.cli.client.Image` widget can be used to visualize 2D image data for example a camera. More details on setting up the image plot are available in the widget documentation of the {ref}`user.widgets.image`.
|
||||
|
||||
### Useful Commands
|
||||
We recommend users to explore the API of the widgets by themselves since we assume that the user interface is supposed to be intuitive and self-explanatory. We appreciate feedback from user in order to constantly improve the experience and allow easy access to the gui, widgets and their functionality. We recommend checking the [API documentation](user.api_reference), but also by using BEC Widgets, exploring the available functions and check their dockstrings.
|
||||
We recommend users to explore the API of the widgets by themselves since we assume that the user interface is supposed to be intuitive and self-explanatory. We appreciate feedback from user in order to constantly improve the experience and allow easy access to the gui, widgets and their functionality. We recommend checking the {ref}`user.api_reference`, but also by using BEC Widgets, exploring the available functions and check their dockstrings.
|
||||
```python
|
||||
gui.new? # shows the dockstring of the new method
|
||||
```
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
```{tab} Overview
|
||||
|
||||
The [`BECProgressbar`](/api_reference/_autosummary/bec_widgets.cli.client.BECProgressBar) widget is a general purpose progress bar that follows the BEC theme and style. It can be embedded in any application to display the progress of a task or operation.
|
||||
The {py:class}`~bec_widgets.cli.client.BECProgressBar` widget is a general purpose progress bar that follows the BEC theme and style. It can be embedded in any application to display the progress of a task or operation.
|
||||
|
||||
## Key Features:
|
||||
- **Modern Design**: The BEC Progressbar widget is designed with a modern and sleek appearance, following the BEC theme.
|
||||
@@ -35,6 +35,8 @@ pb.set_value(50)
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECProgressBar.rst
|
||||
.. autoclass:: bec_widgets.cli.client.BECProgressBar
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`BEC Status Box`](/api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox) widget is designed to monitor the status and health of all running BEC processes. This widget provides a real-time overview of the BEC core services, including DeviceServer, ScanServer, SciHub, ScanBundler, and FileWriter. The top-level display indicates the overall state of the BEC services, while the collapsed view allows users to delve into the status of each individual process. By double-clicking on a specific process, users can access a detailed popup window with live updates of the metrics for that process.
|
||||
The {py:class}`~bec_widgets.cli.client.BECStatusBox` widget is designed to monitor the status and health of all running BEC processes. This widget provides a real-time overview of the BEC core services, including DeviceServer, ScanServer, SciHub, ScanBundler, and FileWriter. The top-level display indicates the overall state of the BEC services, while the collapsed view allows users to delve into the status of each individual process. By double-clicking on a specific process, users can access a detailed popup window with live updates of the metrics for that process.
|
||||
|
||||
## Key Features:
|
||||
- **Comprehensive Service Monitoring**: Track the state of individual BEC services, including real-time updates on their health and status.
|
||||
@@ -33,6 +33,8 @@ Once the `BECStatusBox` is added, users can interact with it to view the status
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox.rst
|
||||
.. autoclass:: bec_widgets.cli.client.BECStatusBox
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -146,8 +146,14 @@ my_gui.show()
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DarkModeButton.rst
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ColorButton.rst
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ColormapSelector.rst
|
||||
.. autoclass:: bec_widgets.cli.client.DarkModeButton
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. autoclass:: bec_widgets.cli.client.ColorButton
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. autoclass:: bec_widgets.cli.client.ColormapSelector
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -39,7 +39,7 @@ The `Reset Button` is used to reset the scan queue. It prompts the user for conf
|
||||
- **Toolbar and Button Options**: Can be configured as a toolbar button or a standard push button.
|
||||
```
|
||||
|
||||
`````{tab} Examples
|
||||
````{tab} Examples
|
||||
|
||||
Integrating these buttons into a BEC GUI layout is straightforward. The following examples demonstrate how to embed these buttons within a custom GUI layout using `QtWidgets`.
|
||||
|
||||
@@ -66,12 +66,21 @@ app.exec_()
|
||||
```
|
||||
|
||||
`ResumeButton`, `ResetButton`, and `AbortButton` may be used in an exactly analogous way.
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.StopButton.rst
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ResumeButton.rst
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.AbortButton.rst
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ResetButton.rst
|
||||
.. autoclass:: bec_widgets.cli.client.StopButton
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. autoclass:: bec_widgets.cli.client.ResumeButton
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. autoclass:: bec_widgets.cli.client.AbortButton
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. autoclass:: bec_widgets.cli.client.ResetButton
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
`````
|
||||
````
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`DAPComboBox`](/api_reference/_autosummary/bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPComboBox) is a widget that extends the functionality of a standard `QComboBox` to allow the user to select a DAP process from all available DAP models.
|
||||
One of its signals `new_dap_config` is designed to be connected to the [`add_dap(str, str, str)`](/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.rst#bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.add_dap) slot from the BECWaveformWidget to add a DAP process.
|
||||
The {py:class}`~bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPComboBox` is a widget that extends the functionality of a standard `QComboBox` to allow the user to select a DAP process from all available DAP models.
|
||||
One of its signals `new_dap_config` is designed to be connected to the {py:class}`~bec_widgets.widgets.plots.waveform.waveform.Waveform.add_dap_curve` slot from the Waveform widget to add a DAP process.
|
||||
|
||||
## Key Features:
|
||||
- **Select DAP model**: Select one of the available DAP models.
|
||||
@@ -30,11 +30,6 @@ The following slots are available for the `DAP ComboBox` widget:
|
||||
- `select_y_axis(str)` : Slot to select the current y axis, emits the `x_axis_updated` signal
|
||||
- `select_fit_model(str)` : Slot to select the current fit model, emits the `fit_model_updated` signal. If x and y axis are set, it will also emit the `new_dap_config` signal.
|
||||
````
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPCombobox.rst
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`Device Browser`](/api_reference/_autosummary/bec_widgets.cli.client.DeviceBrowser) widget provides a user-friendly interface for browsing through all available devices in the current BEC session. As it supports drag functionality, users can easily drag and drop device into other widgets or applications.
|
||||
The {py:class}`~bec_widgets.cli.client.DeviceBrowser` widget provides a user-friendly interface for browsing through all available devices in the current BEC session. As it supports drag functionality, users can easily drag and drop device into other widgets or applications.
|
||||
|
||||
```{note}
|
||||
The `Device Browser` widget is currently under development. Other widgets may not support drag and drop functionality yet.
|
||||
@@ -34,6 +34,8 @@ dock_area.device_browser.DeviceBrowser
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceBrowser.rst
|
||||
.. autoclass:: bec_widgets.cli.client.DeviceBrowser
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -114,12 +114,16 @@ The following Qt properties are also included:
|
||||
|
||||
````{tab} API - ComboBox
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceComboBox.rst
|
||||
.. autoclass:: bec_widgets.cli.client.DeviceComboBox
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
````{tab} API - LineEdit
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceLineEdit.rst
|
||||
.. autoclass:: bec_widgets.cli.client.DeviceLineEdit
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
[`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea) is a powerful and flexible container designed to host various widgets and docks within a grid layout. It provides an environment for organizing and managing complex user interfaces, making it ideal for applications that require multiple tools and data visualizations to be displayed simultaneously. BECDockArea is particularly useful for embedding not only visualization tools but also other interactive components, allowing users to tailor their workspace to their specific needs.
|
||||
`BECDockArea` is a powerful and flexible container designed to host various widgets and docks within a grid layout. It provides an environment for organizing and managing complex user interfaces, making it ideal for applications that require multiple tools and data visualizations to be displayed simultaneously. BECDockArea is particularly useful for embedding not only visualization tools but also other interactive components, allowing users to tailor their workspace to their specific needs.
|
||||
|
||||
- **Flexible Dock Management**: Easily add, remove, and rearrange docks within `BECDockArea`, providing a customized layout for different tasks.
|
||||
- **State Persistence**: Save and restore the state of the dock area, enabling consistent user experiences across sessions.
|
||||
- **Dock Customization**: Add docks with customizable positions, names, and behaviors, such as floating or closable docks.
|
||||
- **Integration with Widgets**: Integrate various widgets like [`WaveformWidget`](user.widgets.waveform_widget), [`ImageWidget`](user.widgets.image_widget), and [`MotorMapWidget`](user.widgets.motor_map) into [`BECDockArea`](/api_reference/_autosummary/bec_widgets.cli.client.BECDockArea), either as standalone tools or as part of a more complex interface.
|
||||
- **Integration with Widgets**: Integrate various widgets like [`WaveformWidget`](user.widgets.waveform_widget), [`ImageWidget`](user.widgets.image_widget), and [`MotorMapWidget`](user.widgets.motor_map) into `BECDockArea`, either as standalone tools or as part of a more complex interface.
|
||||
|
||||
**BEC Dock Area Components Schema**
|
||||
|
||||
@@ -114,7 +114,9 @@ When removing a dock, all widgets within the dock will be removed as well. This
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECDockArea.rst
|
||||
.. autoclass:: bec_widgets.cli.client.BECDockArea
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@ heatmap_widget.v_max = 1000
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.rst
|
||||
.. autoclass:: bec_widgets.widgets.plots.heatmap.heatmap.Heatmap
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -105,6 +105,8 @@ Since the Image Widget does not have prior information about the shape of incomi
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.Image.rst
|
||||
.. autoclass:: bec_widgets.cli.client.Image
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`LMFit Dialog`](/api_reference/_autosummary/bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog.LMFitDialog) is a widget that is developed to be used together with the [`Waveform`](/api_reference/_autosummary/bec_widgets.widgets.plots.waveform.waveform.Waveform) widget. The `Waveform` widget allows user to submit a fit request to BEC's [DAP server](https://bec.readthedocs.io/en/latest/developer/getting_started/architecture.html) choosing from a selection of [LMFit models](https://lmfit.github.io/lmfit-py/builtin_models.html#) to fit monitored data sources. The `LMFit Dialog` provides an interface to monitor these fits, including statistics and fit parameters in real time.
|
||||
Within the `Waveform` widget, the dialog is accessible via the toolbar and will be automatically linked to the current waveform widget. For a more customised use, we can embed the `LMFit Dialog` in a larger GUI using the *BEC Designer*. In this case, one has to connect the [`update_summary_tree`](/api_reference/_autosummary/bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog.LMFitDialog.rst#bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.update_summary_tree) slot of the LMFit Dialog to the [`dap_summary_update`](/api_reference/_autosummary/bec_widgets.widgets.plots.waveform.waveform_widget.Waveform.rst#bec_widgets.widgets.plots.waveform.waveform.Waveform.dap_summary_update) signal of the Waveform widget to ensure its functionality.
|
||||
The `LMFitDialog` is a widget that is developed to be used together with the `Waveform` widget. The `Waveform` widget allows user to submit a fit request to BEC's [DAP server](https://bec.readthedocs.io/en/latest/developer/getting_started/architecture.html) choosing from a selection of [LMFit models](https://lmfit.github.io/lmfit-py/builtin_models.html#) to fit monitored data sources. The `LMFit Dialog` provides an interface to monitor these fits, including statistics and fit parameters in real time.
|
||||
Within the `Waveform` widget, the dialog is accessible via the toolbar and will be automatically linked to the current waveform widget. For a more customised use, we can embed the `LMFit Dialog` in a larger GUI using the *BEC Designer*. In this case, one has to connect the `update_summary_tree` slot of the LMFit Dialog to the `dap_summary_update` signal of the Waveform widget to ensure its functionality.
|
||||
|
||||
|
||||
## Key Features:
|
||||
@@ -34,11 +34,7 @@ waveform.dap_summary_update.connect(lmfit_dialog.update_summary_tree)
|
||||
|
||||
```
|
||||
````
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.rst
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ mm1.map(x_name='aptrx', y_name='aptry')
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.MotorMap.rst
|
||||
.. autoclass:: bec_widgets.cli.client.MotorMap
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -89,6 +89,8 @@ multi_waveform.export_to_matplotlib()
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.MultiWaveform.rst
|
||||
.. autoclass:: bec_widgets.cli.client.MultiWaveform
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
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:
|
||||
```
|
||||
````
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`PositionIndicator`](/api_reference/_autosummary/bec_widgets.cli.client.PositionIndicator) widget is a simple yet effective tool for visually indicating the position of a motor within its set limits. This widget is particularly useful in applications where it is important to provide a visual clue of the motor's current position relative to its minimum and maximum values. The `PositionIndicator` can be easily integrated into your GUI application either through direct code instantiation or by using `BEC Designer`.
|
||||
The `PositionIndicator` widget is a simple yet effective tool for visually indicating the position of a motor within its set limits. This widget is particularly useful in applications where it is important to provide a visual clue of the motor's current position relative to its minimum and maximum values. The `PositionIndicator` can be easily integrated into your GUI application either through direct code instantiation or by using `BEC Designer`.
|
||||
|
||||
## Key Features:
|
||||
- **Position Visualization**: Displays the current position of a motor on a linear scale, showing its location relative to the defined limits.
|
||||
@@ -36,7 +36,7 @@ Within the BEC Designer's [property editor](https://doc.qt.io/qt-6/designer-widg
|
||||
|
||||
````{tab} Examples
|
||||
|
||||
The `PositionIndicator` widget can be embedded in a [`BECDockArea`](#user.widgets.bec_dock_area) or used as an individual component in your application through `BEC Designer`. Below are examples demonstrating how to create and use the `PositionIndicator` from the CLI and also directly within Code.
|
||||
The `PositionIndicator` widget can be embedded in a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BEC Designer`. Below are examples demonstrating how to create and use the `PositionIndicator` from the CLI and also directly within Code.
|
||||
|
||||
## Example 1 - Creating a Position Indicator in Code
|
||||
|
||||
@@ -95,6 +95,8 @@ self.position_indicator.set_value(new_position_value)
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionIndicator.rst
|
||||
.. autoclass:: bec_widgets.cli.client.PositionIndicator
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`PositionerBox`](/api_reference/_autosummary/bec_widgets.cli.client.PositionerBox) widget provides a graphical user interface to control a positioner device within the BEC environment. This widget allows users to interact with a positioner by setting setpoints, tweaking the motor position, and stopping motion. The device selection can be done via a small button under the device label, through `BEC Designer`, or by using the command line interface (CLI). This flexibility makes the `PositionerBox` an essential tool for tasks involving precise position control.
|
||||
The `PositionerBox` widget provides a graphical user interface to control a positioner device within the BEC environment. This widget allows users to interact with a positioner by setting setpoints, tweaking the motor position, and stopping motion. The device selection can be done via a small button under the device label, through `BEC Designer`, or by using the command line interface (CLI). This flexibility makes the `PositionerBox` an essential tool for tasks involving precise position control.
|
||||
|
||||
## Key Features:
|
||||
- **Device Selection**: Easily select a positioner device by clicking the button under the device label or by configuring the widget in `BEC Designer`.
|
||||
@@ -58,6 +58,8 @@ self.positioner_box.set_positioner("motor2")
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionerBox.rst
|
||||
.. autoclass:: bec_widgets.cli.client.PositionerBox
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`PositionerBox2D`](/api_reference/_autosummary/bec_widgets.cli.client.PositionerBox2D) widget is very similar to the [`PositionerBox`](/user/widgets/positioner_box/positioner_box) but allows controlling two positioners at the same time, in a horizontal and vertical orientation respectively. It is intended primarily for controlling axes which have a perpendicular relationship like that. In other cases, it may be better to use a `PositionerGroup` instead.
|
||||
The `PositionerBox2D` widget is very similar to the `PositionerBox` but allows controlling two positioners at the same time, in a horizontal and vertical orientation respectively. It is intended primarily for controlling axes which have a perpendicular relationship like that. In other cases, it may be better to use a `PositionerGroup` instead.
|
||||
|
||||
The `PositionerBox2D` has the same features as the standard `PositionerBox`, but additionally, step buttons which move the positioner by the selected step size, and tweak buttons which move by a tenth of the selected step size.
|
||||
|
||||
@@ -55,6 +55,8 @@ self.positioner_box.set_positioner_verr("samy")
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionerBox2D.rst
|
||||
.. autoclass:: bec_widgets.cli.client.PositionerBox2D
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`Ring Progress Bar`](/api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar) widget is a circular progress bar designed to visualize the progress of tasks in a clear and intuitive manner. This widget is particularly useful in applications where task progress needs to be represented as a percentage. The `Ring Progress Bar` can be controlled directly via its API or can be hooked up to track the progress of a device readback or scan, providing real-time visual feedback.
|
||||
The `RingProgressBar` widget is a circular progress bar designed to visualize the progress of tasks in a clear and intuitive manner. This widget is particularly useful in applications where task progress needs to be represented as a percentage. The `Ring Progress Bar` can be controlled directly via its API or can be hooked up to track the progress of a device readback or scan, providing real-time visual feedback.
|
||||
|
||||
## Key Features:
|
||||
- **Circular Progress Visualization**: Displays a circular progress bar to represent task completion.
|
||||
@@ -98,7 +98,9 @@ progress.set_value([50, 75, 25])
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar.rst
|
||||
.. autoclass:: bec_widgets.cli.client.RingProgressBar
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`BEC Queue`](/api_reference/_autosummary/bec_widgets.cli.client.BECQueue) widget provides a real-time display and control of the BEC scan queue, allowing users to monitor, manage, and control the status of ongoing and pending scans. The widget automatically updates to reflect the current state of the scan queue, displaying critical information such as scan numbers, types, and statuses. Additionally, it provides control options to stop individual scans, stop the entire queue, resume, and reset the queue, making it a powerful tool for managing scan operations in the BEC environment.
|
||||
The `BECQueue` widget provides a real-time display and control of the BEC scan queue, allowing users to monitor, manage, and control the status of ongoing and pending scans. The widget automatically updates to reflect the current state of the scan queue, displaying critical information such as scan numbers, types, and statuses. Additionally, it provides control options to stop individual scans, stop the entire queue, resume, and reset the queue, making it a powerful tool for managing scan operations in the BEC environment.
|
||||
|
||||
## Key Features:
|
||||
- **Real-Time Queue Monitoring**: Displays the current state of the BEC scan queue, with automatic updates as the queue changes.
|
||||
@@ -39,6 +39,8 @@ Once the widget is added, it will automatically display the current scan queue
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECQueue.rst
|
||||
.. autoclass:: bec_widgets.cli.client.BECQueue
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`Scan Control`](/api_reference/_autosummary/bec_widgets.cli.client.ScanControl) widget provides a graphical user interface (GUI) to manage various scan operations in a BEC environment. It is designed to interact with the BEC server, enabling users to start and stop scans. The widget automatically creates the necessary input form based on the scan's signature and gui_config, making it highly adaptable to different scanning processes.
|
||||
The `ScanControl` widget provides a graphical user interface (GUI) to manage various scan operations in a BEC environment. It is designed to interact with the BEC server, enabling users to start and stop scans. The widget automatically creates the necessary input form based on the scan's signature and gui_config, making it highly adaptable to different scanning processes.
|
||||
|
||||
## Key Features:
|
||||
- **Automatic Interface Generation**: Automatically generates a control interface based on scan signatures and `gui_config`.
|
||||
@@ -59,6 +59,8 @@ scan_control = dock_area.new().new(gui.available_widgets.ScanControl)
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ScanControl.rst
|
||||
.. autoclass:: bec_widgets.cli.client.ScanControl
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -34,6 +34,8 @@ The ScatterWaveform widget only plots the data points if both x and y axis motor
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.ScatterWaveform.rst
|
||||
.. autoclass:: bec_widgets.cli.client.ScatterWaveform
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
@@ -104,14 +104,6 @@ The following Qt properties are also included:
|
||||
|
||||
````
|
||||
|
||||
````{tab} API - ComboBox
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.control.device_input.signal_combobox.SignalComboBox.rst
|
||||
```
|
||||
````
|
||||
|
||||
````{tab} API - LineEdit
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.control.device_input.signal_line_edit.SignalLineEdit.rst
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`SignalLabel`](/api_reference/_autosummary/bec_widgets.cli.client.SignalLabel) displays the value of a signal from a device, with optional customization for labels, units, decimal formatting, and signal selection. It is designed for use in BEC (Beamline Experiment Control) GUIs to monitor values which beamline operators might want to keep an eye on, e.g. sample position, flux, hutch state...
|
||||
The `SignalLabel` displays the value of a signal from a device, with optional customization for labels, units, decimal formatting, and signal selection. It is designed for use in BEC (Beamline Experiment Control) GUIs to monitor values which beamline operators might want to keep an eye on, e.g. sample position, flux, hutch state...
|
||||
|
||||
## Key Features:
|
||||
- Display: Shows the current value of a device signal.
|
||||
@@ -88,7 +88,9 @@ The various properties can also be set when the SignalLabel widget is added to a
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.TextBox.rst
|
||||
.. autoclass:: bec_widgets.cli.client.TextBox
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`SpinnerWidget`](/api_reference/_autosummary/bec_widgets.utility.spinner.spinner.SpinnerWidget) is a simple and versatile widget designed to indicate loading or movement within an application. It is commonly used to show that a device is in motion or that an operation is ongoing. The `SpinnerWidget` can be easily integrated into your GUI application either through direct code instantiation or by using `BEC Designer`.
|
||||
The `SpinnerWidget` is a simple and versatile widget designed to indicate loading or movement within an application. It is commonly used to show that a device is in motion or that an operation is ongoing. The `SpinnerWidget` can be easily integrated into your GUI application either through direct code instantiation or by using `BEC Designer`.
|
||||
|
||||
## Key Features:
|
||||
- **Loading Indicator**: Provides a visual indication of ongoing operations or device movement.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`Text Box Widget`](/api_reference/_autosummary/bec_widgets.cli.client.TextBox) is a versatile widget that allows users to display text within the BEC GUI. It supports both plain text and HTML, making it useful for displaying simple messages or more complex formatted content. This widget is particularly suited for integrating textual content directly into the user interface, whether as a standalone message box or as part of a larger application interface.
|
||||
The {py:class}`~bec_widgets.cli.client.TextBox` is a versatile widget that allows users to display text within the BEC GUI. It supports both plain text and HTML, making it useful for displaying simple messages or more complex formatted content. This widget is particularly suited for integrating textual content directly into the user interface, whether as a standalone message box or as part of a larger application interface.
|
||||
|
||||
## Key Features:
|
||||
- **Text Display**: Display either plain text or HTML content, with automatic detection of the format.
|
||||
@@ -45,7 +45,9 @@ text_box.set_html_text("<h1>Welcome to BEC Widgets</h1><p>This is an example of
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.TextBox.rst
|
||||
.. autoclass:: bec_widgets.cli.client.TextBox
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`Toggle Switch`](/api_reference/_autosummary/bec_widgets.cli.client.ToggleSwitch) widget provides a simple, customizable toggle switch that can be used to represent binary states (e.g., on/off, true/false) within a GUI. This widget is designed to be used directly in code or added through `BEC Designer`, making it versatile for various applications where a user-friendly switch is needed.
|
||||
The {py:class}`~bec_widgets.cli.client.ToggleSwitch` widget provides a simple, customizable toggle switch that can be used to represent binary states (e.g., on/off, true/false) within a GUI. This widget is designed to be used directly in code or added through `BEC Designer`, making it versatile for various applications where a user-friendly switch is needed.
|
||||
|
||||
## Key Features:
|
||||
- **Binary State Representation**: Represents a simple on/off state with a smooth toggle animation.
|
||||
|
||||
@@ -101,6 +101,8 @@ print(dap_bpm3a.dap_params)
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.Waveform.rst
|
||||
.. autoclass:: bec_widgets.cli.client.Waveform
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`Website Widget`](/api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget) is a versatile tool that allows users to display websites directly within the BEC GUI. This widget is useful for embedding documentation, dashboards, or any web-based tools within the application interface. It is designed to be integrated within a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BEC Designer`.
|
||||
The {py:class}`~bec_widgets.cli.client.WebsiteWidget` is a versatile tool that allows users to display websites directly within the BEC GUI. This widget is useful for embedding documentation, dashboards, or any web-based tools within the application interface. It is designed to be integrated within a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BEC Designer`.
|
||||
|
||||
## Key Features:
|
||||
- **URL Display**: Set and display any website URL within the widget.
|
||||
@@ -66,6 +66,8 @@ print(f"The current URL is: {current_url}")
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget.rst
|
||||
.. autoclass:: bec_widgets.cli.client.WebsiteWidget
|
||||
: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.1"
|
||||
version = "2.45.3"
|
||||
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): ...
|
||||
|
||||
@@ -6,6 +6,10 @@ from qtpy.QtGui import QTransform
|
||||
|
||||
from bec_widgets.utils import Crosshair
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
# pylint: disable = redefined-outer-name
|
||||
|
||||
@@ -189,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
|
||||
|
||||
@@ -363,3 +352,27 @@ def test_get_transformed_position_with_scale(plot_widget_with_crosshair):
|
||||
# Check that the results match expectations
|
||||
assert row == expected_row
|
||||
assert col == expected_col
|
||||
|
||||
|
||||
def test_ignore_invisible_curves_on_move(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c0 = wf.plot(x=[1, 2, 3], y=[1, 4, 9], name="Curve_0")
|
||||
c1 = wf.plot(x=[1, 2, 3], y=[2, 5, 10], name="Curve_1")
|
||||
wf.hook_crosshair()
|
||||
|
||||
# # Simulate a mouse move at (2,5)
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = wf.plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
# 1) Both curves visible: expect markers for both
|
||||
wf.crosshair.clear_markers()
|
||||
wf.crosshair.mouse_moved(event_mock)
|
||||
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0", "Curve_1"}
|
||||
|
||||
# 2) Hide Curve B and repeat: only Curve_0 should remain
|
||||
c1.setVisible(False)
|
||||
wf.crosshair.clear_markers()
|
||||
wf.crosshair.mouse_moved(event_mock)
|
||||
qtbot.wait(200)
|
||||
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"}
|
||||
|
||||
@@ -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]
|
||||
|
||||
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,36 @@ 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_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 +868,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 +1117,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