1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

..

95 Commits

Author SHA1 Message Date
semantic-release
45ed92494c 2.45.9
Automatically generated by python-semantic-release
2025-12-09 14:21:27 +00:00
5fc96bd299 fix(rpc): add expiration to GUI registry state updates 2025-12-09 15:20:42 +01:00
semantic-release
1ad5df57fe 2.45.8
Automatically generated by python-semantic-release
2025-12-08 14:36:13 +00:00
440e778162 fix(notification_banner): backwards compatibility to push messages from Broker to Centre as dict 2025-12-08 15:35:23 +01:00
fdeb8fcb0f fix(notification_banner): formatted error messages fetched directly from BECMessage; do not repreat notifications ids 2025-12-08 15:35:23 +01:00
5c90983dd4 fix(notification_banner): better contrast in light mode 2025-12-08 15:35:23 +01:00
4171de1e45 fix(notification_banner): expired messages are hidden in notification center but still accessible 2025-12-08 15:35:23 +01:00
semantic-release
f12339e6f9 2.45.7
Automatically generated by python-semantic-release
2025-12-08 14:04:02 +00:00
ce8e5f0bec fix: handle none in literal combobox 2025-12-08 15:03:17 +01:00
semantic-release
7ea9ab5175 2.45.6
Automatically generated by python-semantic-release
2025-11-27 09:43:46 +00:00
b72f0dc6e8 fix(curve): update dap curves if data are set manually 2025-11-27 10:42:58 +01:00
semantic-release
cb9d429884 2.45.5
Automatically generated by python-semantic-release
2025-11-26 13:25:28 +00:00
0a80bd0a92 fix: remove ghost widgets in scan metadata 2025-11-26 14:24:36 +01:00
semantic-release
9bc9d355e2 2.45.4
Automatically generated by python-semantic-release
2025-11-24 13:30:46 +00:00
7d5e702a11 fix(web_links): fixed link to bec widget issues from gitlab to github 2025-11-24 14:29:56 +01:00
40cbf7fe4f fix(main_window): removed hiding scan progressbar animation 2025-11-24 14:29:56 +01:00
semantic-release
7b287c45f2 2.45.3
Automatically generated by python-semantic-release
2025-11-17 19:27:38 +00:00
c9455672b5 fix(fakeredis): add support for additional args 2025-11-17 20:24:44 +01:00
semantic-release
7f06375f9d 2.45.2
Automatically generated by python-semantic-release
2025-11-17 12:30:21 +00:00
d00d786399 fix(test): removed duplicate test in crosshair 2025-11-17 13:29:35 +01:00
a4c465dcaf build: pyqtgraph pin to 0.13.7 2025-11-17 13:29:35 +01:00
semantic-release
d0e94d0da4 2.45.1
Automatically generated by python-semantic-release
2025-11-14 14:13:05 +00:00
bb3cea7fe8 fix(waveform): async_readback can accept 0D data 2025-11-14 15:12:14 +01:00
semantic-release
3c6aa8e138 2.45.0
Automatically generated by python-semantic-release
2025-11-10 19:28:18 +00:00
198684c65d feat(waveform): dap curve can be attached to custom and history curves 2025-11-10 20:27:31 +01:00
617f2df2af chore: add third-party license notice 2025-11-10 13:52:22 +01:00
semantic-release
ef83287126 2.44.0
Automatically generated by python-semantic-release
2025-11-05 21:43:46 +00:00
d5e6f095fe refactor(plot_base): consolidated user access for the PlotBase 2025-11-05 22:42:57 +01:00
b10efc0f40 feat(plot_base): invert x/y axis 2025-11-05 22:42:57 +01:00
44b1dbf911 docs: README rewritten 2025-11-03 14:59:57 +01:00
Klaus Wakonig
e9d381a18a chore: Update stale issue and PR settings to 120 days 2025-11-03 14:46:03 +01:00
semantic-release
b005542df3 2.43.0
Automatically generated by python-semantic-release
2025-10-30 07:58:54 +00:00
13a9175ba5 feat: add pdf viewer widget 2025-10-30 08:58:11 +01:00
semantic-release
3f8e60a14f 2.42.1
Automatically generated by python-semantic-release
2025-10-28 14:48:23 +00:00
6bc1c3c5f1 fix(rpc_server): raise window, even if minimized 2025-10-28 15:47:37 +01:00
semantic-release
9f91eb2e08 2.42.0
Automatically generated by python-semantic-release
2025-10-21 13:17:23 +00:00
1e19092319 feat(positioner_box_2d): added properties to enable/disable vertical and horizontal controls 2025-10-21 15:16:24 +02:00
96664c3923 feat(image_roi): enhance get_coordinates to include rectangle center and dimensions 2025-10-21 15:16:01 +02:00
semantic-release
741ca2fd8a 2.41.1
Automatically generated by python-semantic-release
2025-10-15 11:25:47 +00:00
3941050883 fix(dependencies): bec lib versions fixed 2025-10-15 13:25:01 +02:00
semantic-release
1d746c6829 2.41.0
Automatically generated by python-semantic-release
2025-10-15 10:36:45 +00:00
ef27de40ce fix(image_roi): delete button added to compact version 2025-10-15 12:35:51 +02:00
37df95ead8 fix(image_roi): rois can be removed with right click context menu 2025-10-15 12:35:51 +02:00
c87a6cfce9 feat(image_roi_tree): compact mode added 2025-10-15 12:35:51 +02:00
3d807eaa63 refactor(serializer): upgrade to new serializer interface 2025-10-13 16:11:47 +02:00
28ac9c5cc3 build(bec_lib): version bump to 3.69.3 2025-10-09 15:36:18 +02:00
1dd20d5986 test(deviceconfig-form-update): Add onFailure default to test 2025-10-09 15:36:18 +02:00
semantic-release
13299aeeb3 2.40.0
Automatically generated by python-semantic-release
2025-10-08 11:41:33 +00:00
d681ba538b fix(waveform): cleanup of scan_history dialog if not closed manually before widget 2025-10-08 13:40:48 +02:00
2bf489600e fix(waveform): safeguard for _scan_history_closed 2025-10-08 13:40:48 +02:00
7e88a002b6 fix(waveform): safeguard for if scan_item is a list 2025-10-08 13:40:48 +02:00
20a59af648 fix(curve_tree): scans are always fetched by scan ids 2025-10-08 13:40:48 +02:00
540cfc37be fix(waveform): safeguard added to the fetching history data 2025-10-08 13:40:48 +02:00
e59f27a22d fix(waveform): if scan id and scan number is provided, the scan is fetched from the scan id 2025-10-08 13:40:48 +02:00
df8065ea40 fix(curve_tree): safeguard fetching scan numbers from BEC client 2025-10-08 13:40:48 +02:00
2f3dc2ce6b build(bec_lib): bec_lib dependency raised to 3.68 2025-10-08 13:40:48 +02:00
a006f95f21 test(plotting_framework_e2e): fetching history curve 2025-10-08 13:40:48 +02:00
8111a4a21b fix(curve_tree): fetching scan numbers directly from the bec client 2025-10-08 13:40:48 +02:00
962ab774e6 fix(waveform): fetching scan number is not done from list but from .get_by_scan_number 2025-10-08 13:40:48 +02:00
2f798be7b0 refactor(test_waveform): test waveform renamed 2025-10-08 13:40:48 +02:00
5a5d32312b test(waveform,curve_tree): test extended to cover history curve behaviour 2025-10-08 13:40:48 +02:00
0844a9e119 test(conftest): suppress_message_box for error popups fixture autouse True 2025-10-08 13:40:48 +02:00
db7dd4f8d4 fix(waveform): x_data checked with is scalar instead of len() 2025-10-08 13:40:48 +02:00
f083dff612 feat(waveform): new type of curve - history curve 2025-10-08 13:40:48 +02:00
4be70580a6 refactor(waveform): separate method to fetch scan item from history 2025-10-08 13:40:48 +02:00
d19001c94e fix(waveform): update x suffix label with x property change, do not wait for next update cycle 2025-10-08 13:40:48 +02:00
f25f86522f chore: add dependabot config 2025-10-07 11:12:10 +02:00
semantic-release
948283bc13 2.39.1
Automatically generated by python-semantic-release
2025-10-07 09:00:10 +00:00
50696bce4c fix: explicitly pass the cached readout flag 2025-10-07 10:59:22 +02:00
semantic-release
1d988a4c57 2.39.0
Automatically generated by python-semantic-release
2025-09-24 16:28:40 +00:00
565c0bd1e7 feat(rpc_base): windows can be raised to front from CLI 2025-09-24 11:27:47 -05:00
975404f483 fix(rpc): fix hide/show 2025-09-24 11:27:47 -05:00
semantic-release
165e5e7d84 2.38.4
Automatically generated by python-semantic-release
2025-09-23 15:05:34 +00:00
108ddae6ca fix(image): add support for specifying preview signals through cli 2025-09-23 17:01:00 +02:00
semantic-release
9737acad58 2.38.3
Automatically generated by python-semantic-release
2025-09-23 14:19:21 +00:00
65bc5f5421 fix(ringprogressbar): fix client signature 2025-09-23 16:18:33 +02:00
475ca9f2d8 fix(connector): only flush pending events 2025-09-23 16:18:33 +02:00
bbb5fc6ce1 fix(ringprogressbar): various fixes and improvements 2025-09-23 16:18:33 +02:00
b1b6c5e6a5 test(ringprogressbar): extend e2e test 2025-09-23 16:18:33 +02:00
3e339348dd chore: deprecate 3.10, add 3.13 2025-09-15 13:48:32 +02:00
semantic-release
4f075151d5 2.38.2
Automatically generated by python-semantic-release
2025-09-11 15:01:23 +00:00
0a24ac2c40 fix(waveform):autorange on scan_status 2025-09-11 16:59:35 +02:00
3a2ec9f1b7 test(crosshair): visibility test added with plotbase fixture 2025-09-11 16:59:35 +02:00
4dc4ede1d2 fix(plot_base): crosshair items are excluded from visible curves and from auto_range 2025-09-11 16:59:35 +02:00
556832fd48 fix(waveform): changing curve visibility refresh markers 2025-09-11 16:59:35 +02:00
72b6f74252 fix(crosshair): ignore fetching data and markers from invisible items 2025-09-11 16:59:35 +02:00
b703b37bbd fix(plot_base): visible items injected into plot item 2025-09-11 16:59:35 +02:00
18ef35f22a docs: move to autoapi 2025-09-10 15:05:54 +02:00
fe67a4f325 ci: fix stale issues job permissions; add workflow dispatch option 2025-08-31 09:59:16 +02:00
semantic-release
f1c3d77a45 2.38.1
Automatically generated by python-semantic-release
2025-08-22 10:06:47 +00:00
ad7cdc60dd fix: move thefuzz dependency to prod 2025-08-22 12:06:01 +02:00
semantic-release
ba047fd776 2.38.0
Automatically generated by python-semantic-release
2025-08-19 15:12:14 +00:00
6e05157abb feat(device_manager): DeviceManager view of config session 2025-08-19 17:11:24 +02:00
semantic-release
f4bc759e72 2.37.0
Automatically generated by python-semantic-release
2025-08-19 14:52:20 +00:00
1bec9bd9b2 feat: add explorer widget 2025-08-19 16:51:38 +02:00
116 changed files with 6087 additions and 1189 deletions

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,379 @@
# CHANGELOG
## v2.45.9 (2025-12-09)
### Bug Fixes
- **rpc**: Add expiration to GUI registry state updates
([`5fc96bd`](https://github.com/bec-project/bec_widgets/commit/5fc96bd299115c1849240bae3b37112aad8f5a54))
## v2.45.8 (2025-12-08)
### Bug Fixes
- **notification_banner**: Backwards compatibility to push messages from Broker to Centre as dict
([`440e778`](https://github.com/bec-project/bec_widgets/commit/440e778162ebb359fc33be26e3d22f99b4f9dcfe))
- **notification_banner**: Better contrast in light mode
([`5c90983`](https://github.com/bec-project/bec_widgets/commit/5c90983dd4c3ff96e5625ebda0054a1ac1256227))
- **notification_banner**: Expired messages are hidden in notification center but still accessible
([`4171de1`](https://github.com/bec-project/bec_widgets/commit/4171de1e454c4832513ca599c0fd0eaa365c7c32))
- **notification_banner**: Formatted error messages fetched directly from BECMessage; do not repreat
notifications ids
([`fdeb8fc`](https://github.com/bec-project/bec_widgets/commit/fdeb8fcb0f223d64933f2791585756527c2f41ed))
## v2.45.7 (2025-12-08)
### Bug Fixes
- Handle none in literal combobox
([`ce8e5f0`](https://github.com/bec-project/bec_widgets/commit/ce8e5f0bec7643c9f826e06f987775de95abb91d))
## v2.45.6 (2025-11-27)
### Bug Fixes
- **curve**: Update dap curves if data are set manually
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
## v2.45.5 (2025-11-26)
### Bug Fixes
- Remove ghost widgets in scan metadata
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
## v2.45.4 (2025-11-24)
### Bug Fixes
- **main_window**: Removed hiding scan progressbar animation
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
- **web_links**: Fixed link to bec widget issues from gitlab to github
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
## v2.45.3 (2025-11-17)
### Bug Fixes
- **fakeredis**: Add support for additional args
([`c945567`](https://github.com/bec-project/bec_widgets/commit/c9455672b58b9df101ccd0d80a169bdf6c707f34))
## v2.45.2 (2025-11-17)
### Bug Fixes
- **test**: Removed duplicate test in crosshair
([`d00d786`](https://github.com/bec-project/bec_widgets/commit/d00d786399bca516b8030b9de881b674140bf439))
### Build System
- Pyqtgraph pin to 0.13.7
([`a4c465d`](https://github.com/bec-project/bec_widgets/commit/a4c465dcaf8cb03962dec1e360b7b832a9a5c780))
## v2.45.1 (2025-11-14)
### Bug Fixes
- **waveform**: Async_readback can accept 0D data
([`bb3cea7`](https://github.com/bec-project/bec_widgets/commit/bb3cea7fe800cd5375de5351a72e0944dc86861f))
## v2.45.0 (2025-11-10)
### Chores
- Add third-party license notice
([`617f2df`](https://github.com/bec-project/bec_widgets/commit/617f2df2af41db7692c42d0e10bce4968f36fb94))
### Features
- **waveform**: Dap curve can be attached to custom and history curves
([`198684c`](https://github.com/bec-project/bec_widgets/commit/198684c65d9565e8985156b426b8ef98dcc687cc))
## v2.44.0 (2025-11-05)
### Chores
- Update stale issue and PR settings to 120 days
([`e9d381a`](https://github.com/bec-project/bec_widgets/commit/e9d381a18a425727216f035ecccdad25f3189608))
### Documentation
- Readme rewritten
([`44b1dbf`](https://github.com/bec-project/bec_widgets/commit/44b1dbf911f43dbde4286e2ea541c480f7b834be))
### Features
- **plot_base**: Invert x/y axis
([`b10efc0`](https://github.com/bec-project/bec_widgets/commit/b10efc0f400fe36f7cb0d5998214d50943934d7b))
### Refactoring
- **plot_base**: Consolidated user access for the PlotBase
([`d5e6f09`](https://github.com/bec-project/bec_widgets/commit/d5e6f095fe60223972235acd3ea68389aa7a1a14))
## v2.43.0 (2025-10-30)
### Features
- Add pdf viewer widget
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
## v2.42.1 (2025-10-28)
### Bug Fixes
- **rpc_server**: Raise window, even if minimized
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
## v2.42.0 (2025-10-21)
### Features
- **image_roi**: Enhance get_coordinates to include rectangle center and dimensions
([`96664c3`](https://github.com/bec-project/bec_widgets/commit/96664c3923737df0b09aa8f35df388f9fd630b55))
- **positioner_box_2d**: Added properties to enable/disable vertical and horizontal controls
([`1e19092`](https://github.com/bec-project/bec_widgets/commit/1e190923196f8b28c92dfdd83b9ce90873dd792d))
## v2.41.1 (2025-10-15)
### Bug Fixes
- **dependencies**: Bec lib versions fixed
([`3941050`](https://github.com/bec-project/bec_widgets/commit/3941050883a791f800ab7178af2435ac14f837b6))
## v2.41.0 (2025-10-15)
### Bug Fixes
- **image_roi**: Delete button added to compact version
([`ef27de4`](https://github.com/bec-project/bec_widgets/commit/ef27de40ceee8375d95a0f3a8e451b7d05d0ae2c))
- **image_roi**: Rois can be removed with right click context menu
([`37df95e`](https://github.com/bec-project/bec_widgets/commit/37df95ead8d6a07a6c5794a97a486d9f380004cc))
### Build System
- **bec_lib**: Version bump to 3.69.3
([`28ac9c5`](https://github.com/bec-project/bec_widgets/commit/28ac9c5cc369bdfa712c70c45591243631c65066))
### Features
- **image_roi_tree**: Compact mode added
([`c87a6cf`](https://github.com/bec-project/bec_widgets/commit/c87a6cfce9c36588b32f5279e63072bc2646c36f))
### Refactoring
- **serializer**: Upgrade to new serializer interface
([`3d807ea`](https://github.com/bec-project/bec_widgets/commit/3d807eaa63980fd2bb11661696c4d8548fffde8c))
### Testing
- **deviceconfig-form-update**: Add onFailure default to test
([`1dd20d5`](https://github.com/bec-project/bec_widgets/commit/1dd20d5986485f3bfe7ee02596ca23027ec4b756))
## v2.40.0 (2025-10-08)
### Bug Fixes
- **curve_tree**: Fetching scan numbers directly from the bec client
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
- **curve_tree**: Safeguard fetching scan numbers from BEC client
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
- **curve_tree**: Scans are always fetched by scan ids
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
- **waveform**: Safeguard added to the fetching history data
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
- **waveform**: Safeguard for _scan_history_closed
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
- **waveform**: Safeguard for if scan_item is a list
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
- **waveform**: X_data checked with is scalar instead of len()
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
### Build System
- **bec_lib**: Bec_lib dependency raised to 3.68
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
### Chores
- Add dependabot config
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
### Features
- **waveform**: New type of curve - history curve
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
### Refactoring
- **test_waveform**: Test waveform renamed
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
- **waveform**: Separate method to fetch scan item from history
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
### Testing
- **conftest**: Suppress_message_box for error popups fixture autouse True
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
- **plotting_framework_e2e**: Fetching history curve
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
- **waveform,curve_tree**: Test extended to cover history curve behaviour
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
## v2.39.1 (2025-10-07)
### Bug Fixes
- Explicitly pass the cached readout flag
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
## v2.39.0 (2025-09-24)
### Bug Fixes
- **rpc**: Fix hide/show
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
### Features
- **rpc_base**: Windows can be raised to front from CLI
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
## v2.38.4 (2025-09-23)
### Bug Fixes
- **image**: Add support for specifying preview signals through cli
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
## v2.38.3 (2025-09-23)
### Bug Fixes
- **connector**: Only flush pending events
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
- **ringprogressbar**: Fix client signature
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
- **ringprogressbar**: Various fixes and improvements
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
### Chores
- Deprecate 3.10, add 3.13
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
### Testing
- **ringprogressbar**: Extend e2e test
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
## v2.38.2 (2025-09-11)
### Bug Fixes
- **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
- Move thefuzz dependency to prod
([`ad7cdc6`](https://github.com/bec-project/bec_widgets/commit/ad7cdc60dd6da6c5291f8b42932aacb12aa671a6))
## v2.38.0 (2025-08-19)
### Features
- **device_manager**: Devicemanager view of config session
([`6e05157`](https://github.com/bec-project/bec_widgets/commit/6e05157abb61ec569eec10ff7295c28cb6a2ec45))
## v2.37.0 (2025-08-19)
### Features
- Add explorer widget
([`1bec9bd`](https://github.com/bec-project/bec_widgets/commit/1bec9bd9b2238ed484e8d25e691326efe5730f6b))
## v2.36.0 (2025-08-18)
### Features

213
README.md
View File

@@ -1,81 +1,200 @@
# BEC Widgets
![banner_opti](https://github.com/user-attachments/assets/44e483be-3f0d-4eb0-bd98-613157456b81)
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
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`: dragdock, 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 zeroglue 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.
![dock_area_example](https://github.com/user-attachments/assets/219a2806-19a8-4a07-9734-b7b554850833)
### 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 autoconnect to BEC/Redis on startup, so your UI is operational immediately.
![designer_opti](https://github.com/user-attachments/assets/fed4843c-1cce-438a-b41f-6636fa5e1545)
### 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.
![rpc_opti](https://github.com/user-attachments/assets/666be7fb-9a0d-44c2-8d44-2f9d1dae4497)
### 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 daytoday 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
View 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)

View File

@@ -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 ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan 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

View File

@@ -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):
"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid_container.setLayout(QVBoxLayout())
self._layout.addWidget(self._form_grid_container)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label = QLabel(parent=self._form_grid, text=item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1)
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
gl = self._form_grid.layout()
while w := gl.takeAt(0):
w = w.widget()
if hasattr(w, "teardown"):
w.teardown()
w.deleteLater()
self._form_grid_container.layout().removeWidget(self._form_grid)
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid = QGridLayout(self)
new_grid.setContentsMargins(0, 0, 0, 0)
return new_grid

View File

@@ -3,8 +3,20 @@ from __future__ import annotations
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 types import GenericAlias, NoneType, UnionType
from typing import (
Any,
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
Optional,
OrderedDict,
TypeVar,
get_args,
)
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -61,7 +73,7 @@ class FormItemSpec(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias
item_type: type | UnionType | GenericAlias | Optional[Any]
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
@@ -178,6 +190,10 @@ class DynamicFormItem(QWidget):
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@SafeSlot()
def clear(self, *_):
return
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
@@ -194,11 +210,17 @@ class DynamicFormItem(QWidget):
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
self._clear_button.clicked.connect(self.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
def teardown(self):
self._layout.deleteLater()
self._layout.removeWidget(self._main_widget)
self._main_widget.deleteLater()
self._main_widget = None
class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
@@ -350,11 +372,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):
@@ -530,11 +554,14 @@ class StrLiteralFormItem(DynamicFormItem):
self._layout.addWidget(self._main_widget)
def getValue(self):
if self._main_widget.currentIndex() == -1:
return None
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
return
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
@@ -545,15 +572,39 @@ class StrLiteralFormItem(DynamicFormItem):
self._main_widget.setCurrentIndex(-1)
class OptionalStrLiteralFormItem(StrLiteralFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(get_args(self._spec.info.annotation)[0])
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
def _is_string_literal(t: type):
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
def _is_optional_string_literal(t: type):
if not hasattr(t, "__args__"):
return False
if len(t.__args__) != 2:
return False
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
return True
return False
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (
lambda spec: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
"literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
"optional_literal_str": (
lambda spec: _is_optional_string_literal(spec.info.annotation),
OptionalStrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
@@ -604,6 +655,8 @@ if __name__ == "__main__": # pragma: no cover
value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
literal: Literal["a", "b", "c"]
nullable_literal: Literal["a", "b", "c"] | None = None
app = QApplication([])
w = QWidget()
@@ -611,7 +664,7 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec)
items.append(widg)

View File

@@ -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]
@@ -200,6 +229,7 @@ class RPCServer:
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
expire=60,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
@@ -229,6 +259,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:

View File

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

View File

@@ -0,0 +1,204 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
class CollapsibleSection(QWidget):
"""A widget that combines a header button with any content widget for collapsible sections
This widget contains a header button with a title and a content widget.
The content widget can be any QWidget. The header button can be expanded or collapsed.
The header also contains an "Add" button that is only visible when hovering over the section.
Signals:
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
onto another section for reordering.
Arguments are (source_title, target_title).
"""
section_reorder_requested = Signal(str, str) # (source_title, target_title)
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
self.setAcceptDrops(True)
self._expanded = True
# Setup layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
self.main_layout.setSpacing(0)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 4, 0)
header_layout.setSpacing(0)
# Create header button
self.header_button = QPushButton()
self.header_button.clicked.connect(self.toggle_expanded)
# Enable drag and drop for reordering
self.header_button.setAcceptDrops(True)
self.header_button.mousePressEvent = self._header_mouse_press_event
self.header_button.mouseMoveEvent = self._header_mouse_move_event
self.header_button.dragEnterEvent = self._header_drag_enter_event
self.header_button.dropEvent = self._header_drop_event
self.drag_start_position = None
# Add header to layout
header_layout.addWidget(self.header_button)
header_layout.addStretch()
self.header_add_button = QPushButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(20, 20)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
self._update_expanded_state()
def set_widget(self, widget):
"""Set the content widget for this collapsible section"""
# Remove existing content widget if any
if self.content_widget and self.content_widget.parent() == self:
self.main_layout.removeWidget(self.content_widget)
self.content_widget.close()
self.content_widget.deleteLater()
self.content_widget = widget
if self.content_widget:
self.main_layout.addWidget(self.content_widget)
self._update_expanded_state()
def _update_appearance(self):
"""Update the header button appearance based on expanded state"""
# Use material icons with consistent sizing to match tree items
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
self.header_button.setIcon(icon)
self.header_button.setText(self.title)
# Get theme colors
palette = get_theme_palette()
text_color = palette.text().color().name()
self.header_button.setStyleSheet(
f"""
QPushButton {{
font-weight: bold;
text-align: left;
margin: 0;
padding: 0px;
border: none;
background: transparent;
color: {text_color};
icon-size: 20px 20px;
}}
"""
)
def toggle_expanded(self):
"""Toggle the expanded state and update size policy"""
self.expanded = not self.expanded
self._update_expanded_state()
def _update_expanded_state(self):
"""Update the expanded state based on current state"""
self._update_appearance()
if self.expanded:
if self.content_widget:
self.content_widget.show()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
else:
if self.content_widget:
self.content_widget.hide()
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
@SafeProperty(bool)
def expanded(self) -> bool:
"""Get the expanded state"""
return self._expanded
@expanded.setter
def expanded(self, value: bool):
"""Set the expanded state programmatically"""
if not isinstance(value, bool):
raise ValueError("Expanded state must be a boolean")
if self._expanded == value:
return
self._expanded = value
self._update_appearance()
def connect_add_button(self, slot):
"""Connect a slot to the add button's clicked signal.
Args:
slot: The function to call when the add button is clicked.
"""
self.header_add_button.clicked.connect(slot)
def _header_mouse_press_event(self, event):
"""Handle mouse press on header for drag start"""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_position = event.pos()
QPushButton.mousePressEvent(self.header_button, event)
def _header_mouse_move_event(self, event):
"""Handle mouse move to start drag operation"""
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
# Check if we've moved far enough to start a drag
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
self._start_drag()
QPushButton.mouseMoveEvent(self.header_button, event)
def _start_drag(self):
"""Start the drag operation with a properly aligned widget pixmap"""
drag = QDrag(self.header_button)
mime_data = QMimeData()
mime_data.setText(f"section:{self.title}")
drag.setMimeData(mime_data)
# Grab a pixmap of the widget
widget_pixmap = self.header_button.grab()
drag.setPixmap(widget_pixmap)
# Set the hotspot to where the mouse was pressed on the widget
drag.setHotSpot(self.drag_start_position)
drag.exec_(Qt.MoveAction)
def _header_drag_enter_event(self, event):
"""Handle drag enter on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
event.acceptProposedAction()
else:
event.ignore()
def _header_drop_event(self, event):
"""Handle drop on header"""
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
source_title = event.mimeData().text().replace("section:", "")
if source_title != self.title:
# Emit signal to parent to handle reordering
self.section_reorder_requested.emit(source_title, self.title)
event.acceptProposedAction()
else:
event.ignore()

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
class Explorer(BECWidget, QWidget):
"""
A widget that combines multiple collapsible sections for an explorer-like interface.
Each section can be expanded or collapsed, and sections can be reordered. The explorer
can contain also sub-explorers for nested structures.
"""
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
# Main layout
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# Splitter for sections
self.splitter = QSplitter(Qt.Orientation.Vertical)
self.main_layout.addWidget(self.splitter)
# Spacer for when all sections are collapsed
self.expander = QSpacerItem(0, 0)
self.main_layout.addItem(self.expander)
# Registry of sections
self.sections: list[CollapsibleSection] = []
# Setup splitter styling
self._setup_splitter_styling()
def add_section(self, section: CollapsibleSection) -> None:
"""
Add a collapsible section to the explorer
Args:
section (CollapsibleSection): The section to add
"""
if not isinstance(section, CollapsibleSection):
raise TypeError("section must be an instance of CollapsibleSection")
if section in self.sections:
return
self.sections.append(section)
self.splitter.addWidget(section)
# Connect the section's toggle to update spacer
section.header_button.clicked.connect(self._update_spacer)
# Connect section reordering if supported
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.connect(self._handle_section_reorder)
self._update_spacer()
def remove_section(self, section: CollapsibleSection) -> None:
"""
Remove a collapsible section from the explorer
Args:
section (CollapsibleSection): The section to remove
"""
if section not in self.sections:
return
self.sections.remove(section)
section.deleteLater()
section.close()
# Disconnect signals
try:
section.header_button.clicked.disconnect(self._update_spacer)
if hasattr(section, "section_reorder_requested"):
section.section_reorder_requested.disconnect(self._handle_section_reorder)
except RuntimeError:
# Signals already disconnected
pass
self._update_spacer()
def get_section(self, title: str) -> CollapsibleSection | None:
"""Get a section by its title"""
for section in self.sections:
if section.title == title:
return section
return None
def _setup_splitter_styling(self) -> None:
"""Setup the splitter styling with theme colors"""
palette = get_theme_palette()
separator_color = palette.mid().color()
self.splitter.setStyleSheet(
f"""
QSplitter::handle {{
height: 0.1px;
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
}}
"""
)
def _update_spacer(self) -> None:
"""Update the spacer size based on section states"""
any_expanded = any(section.expanded for section in self.sections)
if any_expanded:
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
else:
self.expander.changeSize(
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
)
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
"""Handle reordering of sections"""
if source_title == target_title:
return
source_section = self.get_section(source_title)
target_section = self.get_section(target_title)
if not source_section or not target_section:
return
# Get current indices
source_index = self.splitter.indexOf(source_section)
target_index = self.splitter.indexOf(target_section)
if source_index == -1 or target_index == -1:
return
# Insert at target position
self.splitter.insertWidget(target_index, source_section)
# Update sections
self.sections.remove(source_section)
self.sections.insert(target_index, source_section)
if __name__ == "__main__":
import os
from qtpy.QtWidgets import QApplication, QLabel
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
app = QApplication([])
explorer = Explorer()
section = CollapsibleSection(title="SCRIPTS", indentation=0)
script_explorer = Explorer()
script_widget = ScriptTreeWidget()
local_scripts_section = CollapsibleSection(title="Local")
local_scripts_section.set_widget(script_widget)
script_widget.set_directory(os.path.abspath("./"))
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
explorer.add_section(section)
shared_script_section = CollapsibleSection(title="Shared")
shared_script_widget = ScriptTreeWidget()
shared_script_widget.set_directory(os.path.abspath("./"))
shared_script_section.set_widget(shared_script_widget)
script_explorer.add_section(shared_script_section)
macros_section = CollapsibleSection(title="MACROS", indentation=0)
macros_section.set_widget(QLabel("Macros will be implemented later"))
explorer.add_section(macros_section)
explorer.show()
app.exec()

View File

@@ -0,0 +1,387 @@
import os
from pathlib import Path
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
from qtpy.QtGui import QAction, QPainter
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.file_actions: list[QAction] = []
self.dir_actions: list[QAction] = []
self.button_rects: list[QRect] = []
self.current_file_path = ""
def add_file_action(self, action: QAction) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
self.file_actions.clear()
self.dir_actions.clear()
def paint(self, painter, option, index):
"""Paint the item with action buttons on hover"""
# Paint the default item
super().paint(painter, option, index)
# Early return if not hovering over this item
if index != self.hovered_index:
return
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, QSortFilterProxyModel):
return
source_index = proxy_model.mapToSource(index)
source_model = proxy_model.sourceModel()
if not isinstance(source_model, QFileSystemModel):
return
is_dir = source_model.isDir(source_index)
file_path = source_model.filePath(source_index)
self.current_file_path = file_path
# Choose appropriate actions based on item type
actions = self.dir_actions if is_dir else self.file_actions
if actions:
self._draw_action_buttons(painter, option, actions)
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
spacing = 2
# Calculate total width needed for all buttons
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
# Clear previous button rects and create new ones
self.button_rects.clear()
# Calculate starting position (right side of the item)
start_x = option.rect.right() - total_width - margin
current_x = start_x
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Get theme colors for better integration
palette = get_theme_palette()
button_bg = palette.button().color()
button_bg.setAlpha(150) # Semi-transparent
for action in actions:
if not action.isVisible():
continue
# Calculate button position
button_rect = QRect(
current_x,
option.rect.top() + (option.rect.height() - button_size) // 2,
button_size,
button_size,
)
self.button_rects.append(button_rect)
# Draw button background
painter.setBrush(button_bg)
painter.setPen(palette.mid().color())
painter.drawRoundedRect(button_rect, 3, 3)
# Draw action icon
icon = action.icon()
if not icon.isNull():
icon_rect = button_rect.adjusted(2, 2, -2, -2)
icon.paint(painter, icon_rect)
# Move to next button position
current_x += button_size + spacing
painter.restore()
def editorEvent(self, event, model, option, index):
"""Handle mouse events for action buttons"""
# Early return if not a left click
if not (
event.type() == event.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton
):
return super().editorEvent(event, model, option, index)
# Early return if not a proxy model
if not isinstance(model, QSortFilterProxyModel):
return super().editorEvent(event, model, option, index)
source_index = model.mapToSource(index)
source_model = model.sourceModel()
# Early return if not a file system model
if not isinstance(source_model, QFileSystemModel):
return super().editorEvent(event, model, option, index)
is_dir = source_model.isDir(source_index)
actions = self.dir_actions if is_dir else self.file_actions
# Check which button was clicked
visible_actions = [action for action in actions if action.isVisible()]
for i, button_rect in enumerate(self.button_rects):
if button_rect.contains(event.pos()) and i < len(visible_actions):
# Trigger the action
visible_actions[i].trigger()
return True
return super().editorEvent(event, model, option, index)
def set_hovered_index(self, index):
"""Set the currently hovered index"""
self.hovered_index = index
class ScriptTreeWidget(QWidget):
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
file_selected = Signal(str) # Script file path selected
file_open_requested = Signal(str) # File open button clicked
file_renamed = Signal(str, str) # Old path, new path
def __init__(self, parent=None):
super().__init__(parent)
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)
# Create and set custom delegate
self.delegate = FileItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for files
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
action.action.triggered.connect(self._on_file_open_requested)
self.delegate.add_file_action(action.action)
# Remove unnecessary columns
self.tree.setColumnHidden(1, True) # Hide size column
self.tree.setColumnHidden(2, True) # Hide type column
self.tree.setColumnHidden(3, True) # Hide date modified column
# Apply BEC styling
self._apply_styling()
# Script specific properties
self.directory = None
# Connect signals
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
# Install event filter for hover tracking
self.tree.viewport().installEventFilter(self)
# Add to layout
layout.addWidget(self.tree)
def _apply_styling(self):
"""Apply styling to the tree widget"""
# Get theme colors for subtle tree lines
palette = get_theme_palette()
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
}}
QTreeView::branch {{
border-image: none;
background: transparent;
}}
QTreeView::item {{
border: none;
padding: 0px;
margin: 0px;
}}
QTreeView::item:hover {{
background: palette(midlight);
border: none;
padding: 0px;
margin: 0px;
text-decoration: none;
}}
QTreeView::item:selected {{
background: palette(highlight);
color: palette(highlighted-text);
}}
QTreeView::item:selected:hover {{
background: palette(highlight);
}}
"""
self.tree.setStyleSheet(tree_style)
def eventFilter(self, obj, event):
"""Handle mouse move events for hover tracking"""
# Early return if not the tree viewport
if obj != self.tree.viewport():
return super().eventFilter(obj, event)
if event.type() == event.Type.MouseMove:
index = self.tree.indexAt(event.pos())
if index.isValid():
self.delegate.set_hovered_index(index)
else:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
if event.type() == event.Type.Leave:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
return super().eventFilter(obj, event)
def set_directory(self, directory):
"""Set the scripts directory"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
self.tree.expandAll()
def _on_item_clicked(self, index: QModelIndex):
"""Handle item clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
path_obj = Path(file_path)
# Only emit signal for Python files
if path_obj.suffix.lower() == ".py":
logger.info(f"Script selected: {file_path}")
self.file_selected.emit(file_path)
def _on_item_double_clicked(self, index: QModelIndex):
"""Handle item double-clicks"""
# Map proxy index back to source index
source_index = self.proxy_model.mapToSource(index)
# Early return for directories
if self.model.isDir(source_index):
return
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
# Emit signal to open the file
logger.info(f"File open requested via double-click: {file_path}")
self.file_open_requested.emit(file_path)
def _on_file_open_requested(self):
"""Handle file open action triggered"""
logger.info("File open requested")
# Early return if no hovered item
if not self.delegate.hovered_index.isValid():
return
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
file_path = self.model.filePath(source_index)
# Early return if not a valid file
if not file_path or not os.path.isfile(file_path):
return
self.file_open_requested.emit(file_path)
def add_file_action(self, action: QAction) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action: QAction) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_action(action)
def clear_actions(self) -> None:
"""Remove all actions from items"""
self.delegate.clear_actions()
def refresh(self):
"""Refresh the tree view"""
if self.directory is None:
return
self.model.setRootPath("") # Reset
root_index = self.model.setRootPath(self.directory)
proxy_root_index = self.proxy_model.mapFromSource(root_index)
self.tree.setRootIndex(proxy_root_index)
def expand_all(self):
"""Expand all items in the tree"""
self.tree.expandAll()
def collapse_all(self):
"""Collapse all items in the tree"""
self.tree.collapseAll()

View File

@@ -14,13 +14,14 @@ from __future__ import annotations
import json
import sys
from datetime import datetime
from enum import Enum, auto
from enum import Enum
from typing import Literal
from uuid import uuid4
import pyqtgraph as pg
from bec_lib.alarm_handler import Alarms # external enum
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import ErrorInfo
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QObject, QTimer
@@ -28,6 +29,7 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.utils import BECConnector
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetIO
@@ -53,10 +55,10 @@ DARK_PALETTE = {
}
LIGHT_PALETTE = {
"base": "#e9ecef",
"title": "#212121",
"body": "#424242",
"separator": "rgba(0,0,0,40)",
"base": "#f5f5f7",
"title": "#111827",
"body": "#374151",
"separator": "rgba(15,23,42,40)",
}
@@ -108,6 +110,7 @@ class NotificationToast(QFrame):
self._kind = kind if isinstance(kind, SeverityKind) else SeverityKind(kind)
self._traceback = traceback
self._accent_color = QtGui.QColor(SEVERITY[self._kind.value]["color"])
self._accent_alpha = 50
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.created = datetime.now()
@@ -379,22 +382,31 @@ class NotificationToast(QFrame):
# buttons (text colour)
base_btn_color = palette["title"]
card_bg = QtGui.QColor(palette["base"])
# tune card background and hover contrast per theme
if theme == "light":
card_bg.setAlphaF(0.98)
btn_hover = self._accent_color.darker(105).name()
else:
card_bg.setAlphaF(0.88)
btn_hover = self._accent_color.name()
self.setStyleSheet(
"""
#NotificationToast {
background: transparent;
f"""
#NotificationToast {{
background: {card_bg.name(QtGui.QColor.HexArgb)};
border-radius: 12px;
color: %s;
}
#NotificationToast QPushButton {
color: {base_btn_color};
border: 1px solid {palette["separator"]};
}}
#NotificationToast QPushButton {{
background: transparent;
border: none;
color: %s;
color: {base_btn_color};
font-size: 14px;
}
#NotificationToast QPushButton:hover { color: %s; }
}}
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
"""
% (base_btn_color, base_btn_color, self._accent_color.name())
)
# traceback panel colours
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
@@ -407,6 +419,37 @@ class NotificationToast(QFrame):
"""
)
# icon glyph vs badge background: darker badge, lighter icon in light mode
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
icon = material_icon(
icon_name=SEVERITY[self._kind.value]["icon"],
color=icon_fg,
filled=True,
size=(24, 24),
convert_to_pixmap=False,
)
self._icon_btn.setIcon(icon)
badge_bg = QtGui.QColor(self._accent_color)
if theme == "light":
# darken and strengthen the badge on light cards for contrast
badge_bg = badge_bg.darker(115)
badge_bg.setAlphaF(0.70)
else:
badge_bg.setAlphaF(0.30)
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
self._icon_btn.setStyleSheet(
f"""
QToolButton {{
background: {icon_bg};
border: none;
border-radius: 20px;
}}
"""
)
# stronger accent wash in light mode, slightly stronger in dark too
self._accent_alpha = 110 if theme == "light" else 60
self.update()
########################################
@@ -488,7 +531,9 @@ class NotificationToast(QFrame):
# accent gradient, fades to transparent
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
accent = QtGui.QColor(self._accent_color)
accent.setAlpha(50)
if getattr(self, "_theme", "dark") == "light":
accent = accent.darker(115)
accent.setAlpha(getattr(self, "_accent_alpha", 50))
grad.setColorAt(0.0, accent)
fade = QtGui.QColor(self._accent_color)
fade.setAlpha(0)
@@ -690,7 +735,6 @@ class NotificationCentre(QScrollArea):
toast.notification_id = notification_id
broker = BECNotificationBroker()
toast.closed.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
toast.expired.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
toast.closed.connect(lambda: self._hide_notification(toast))
toast.expired.connect(lambda t=toast: self._handle_expire(t))
toast.expanded.connect(self._adjust_height)
@@ -1016,32 +1060,55 @@ class BECNotificationBroker(BECConnector, QObject):
"""
Called when a new alarm arrives. Builds and pushes a toast to each centre
with a shared notification_id, and hooks its close/expire signals.
Args:
msg(dict): The message containing alarm details.
meta(dict): Metadata about the alarm.
"""
msg = msg or {}
meta = meta or {}
centres = WidgetIO.find_widgets(NotificationCentre)
kind = self._banner_kind_from_severity(msg.get("severity", 0))
# Normalise the incoming info payload (can be ErrorInfo, dict or missing entirely)
raw_info = msg.get("info")
if isinstance(raw_info, dict):
try:
raw_info = ErrorInfo(**raw_info)
except Exception:
raw_info = None
notification_id = getattr(raw_info, "id", None) or uuid4().hex
# build title and body
scan_id = meta.get("scan_id")
scan_number = meta.get("scan_number")
formatted_trace = self._err_util.format_traceback(msg.get("msg", ""))
short_msg = self._err_util.parse_error_message(formatted_trace)
title = msg.get("alarm_type", "Alarm")
alarm_type = msg.get("alarm_type") or getattr(raw_info, "exception_type", None) or "Alarm"
title = alarm_type
if scan_number:
title += f" - Scan #{scan_number}"
body_text = short_msg
# build detailed traceback
sections: list[str] = []
if scan_id:
sections.extend(["-------- SCAN_ID --------\n", scan_id])
sections.extend(["-------- TRACEBACK --------", formatted_trace])
source = msg.get("source")
if source:
source_pretty = json.dumps(source, indent=4, default=str)
sections.extend(["", "-------- SOURCE --------", source_pretty])
detailed_trace = "\n".join(sections)
trace_text = getattr(raw_info, "error_message", None) or msg.get("msg") or ""
compact_msg = getattr(raw_info, "compact_error_message", None)
# Prefer the compact message; fall back to parsing the traceback for a humanreadable snippet
body_text = compact_msg or self._err_util.parse_error_message(trace_text)
# build detailed traceback for the expandable panel
detailed_trace: str | None = None
if trace_text:
sections: list[str] = []
if scan_id:
sections.extend(["-------- SCAN_ID --------\n", scan_id])
sections.extend(["-------- TRACEBACK --------", trace_text])
detailed_trace = "\n".join(sections)
lifetime = 0 if kind == SeverityKind.MAJOR else 5_000
# generate one ID for all toasts of this event
notification_id = uuid4().hex
if notification_id in self._active_notifications:
return # already posted
# record this notification for future centres
self._active_notifications[notification_id] = {
"title": title,
@@ -1059,9 +1126,8 @@ class BECNotificationBroker(BECConnector, QObject):
lifetime_ms=lifetime,
notification_id=notification_id,
)
# broadcast any close or expire
# broadcast close events (expiry is handled locally to keep history)
toast.closed.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
toast.expired.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
@SafeSlot(dict, dict)
def on_scan_status(self, msg: dict, meta: dict) -> None:
@@ -1086,6 +1152,13 @@ class BECNotificationBroker(BECConnector, QObject):
Translate an integer severity (0/1/2) into a SeverityKind enum.
Unknown values fall back to SeverityKind.WARNING.
"""
if isinstance(severity, SeverityKind):
return severity
if isinstance(severity, str):
try:
return SeverityKind(severity)
except ValueError:
pass
try:
return SeverityKind[Alarms(severity).name] # e.g. WARNING → SeverityKind.WARNING
except (ValueError, KeyError):
@@ -1164,10 +1237,10 @@ class DemoWindow(QMainWindow): # pragma: no cover
# ----- wiring ------------------------------------------------------------
self._counter = 1
self.info_btn.clicked.connect(lambda: self._post("info"))
self.warning_btn.clicked.connect(lambda: self._post("warning"))
self.minor_btn.clicked.connect(lambda: self._post("minor"))
self.major_btn.clicked.connect(lambda: self._post("major"))
self.info_btn.clicked.connect(lambda: self._post(SeverityKind.INFO))
self.warning_btn.clicked.connect(lambda: self._post(SeverityKind.WARNING))
self.minor_btn.clicked.connect(lambda: self._post(SeverityKind.MINOR))
self.major_btn.clicked.connect(lambda: self._post(SeverityKind.MAJOR))
# Raise buttons simulate alarms
self.raise_warning_btn.clicked.connect(lambda: self._raise_error(Alarms.WARNING))
self.raise_minor_btn.clicked.connect(lambda: self._raise_error(Alarms.MINOR))
@@ -1183,30 +1256,28 @@ class DemoWindow(QMainWindow): # pragma: no cover
indicator.hide_all_requested.connect(self.notification_centre.hide_all)
# ------------------------------------------------------------------
def _post(self, kind):
expire = 0 if kind == "error" else 5000
trace = (
'Traceback (most recent call last):\n File "<stdin>", line 1\nZeroDivisionError: 1/0'
if kind == "error"
else None
)
self.notification_centre.add_notification(
title=f"{kind.capitalize()} #{self._counter}",
body="Lorem ipsum dolor sit amet.",
kind=SeverityKind(kind),
lifetime_ms=expire,
traceback=trace,
)
def _post(self, kind: SeverityKind):
"""
Send a simple notification through the broker (non-error case).
"""
msg = {
"severity": kind.value, # handled by broker for SeverityKind
"alarm_type": f"{kind.value.capitalize()}",
"msg": f"{kind.value.capitalize()} #{self._counter}",
}
self.notification_broker.post_notification(msg, meta={})
self._counter += 1
def _raise_error(self, severity):
"""Simulate an error that would be caught by the notification broker."""
self.notification_broker.client.connector.raise_alarm(
severity=severity,
alarm_type="ValueError",
source={"device": "samx", "source": "async_file_writer"},
msg=f"test alarm",
metadata={"test": 1},
info=ErrorInfo(
id=uuid4().hex,
exception_type="ValueError",
error_message="An example error occurred in DemoWindowApp.",
compact_error_message="An example error occurred.",
),
)
# this part is same as implemented in the BECMainWindow
@@ -1225,6 +1296,7 @@ class DemoWindow(QMainWindow): # pragma: no cover
def main(): # pragma: no cover
app = QtWidgets.QApplication(sys.argv)
apply_theme("dark")
win = DemoWindow()
win.show()
sys.exit(app.exec())

View File

@@ -12,4 +12,4 @@ class BECWebLinksMixin:
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
SCAN_PROGRESS_HEIGHT = 12 # px
def __init__(
self,
@@ -201,8 +201,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
@@ -219,62 +219,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
@@ -474,8 +420,6 @@ class BECMainWindow(BECWidget, QMainWindow):
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup

View File

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

View File

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

View File

@@ -0,0 +1,631 @@
"""Module with the device table view implementation."""
from __future__ import annotations
import copy
import json
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
from thefuzz import fuzz
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
# Threshold for fuzzy matching, careful with adjusting this. 80 seems good
FUZZY_SEARCH_THRESHOLD = 80
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
@staticmethod
def dict_to_str(d: dict) -> str:
"""Convert a dictionary to a formatted string."""
return json.dumps(d, indent=4)
def helpEvent(self, event, view, option, index):
"""Override to show tooltip when hovering."""
if event.type() != QtCore.QEvent.ToolTip:
return super().helpEvent(event, view, option, index)
model: DeviceFilterProxyModel = index.model()
model_index = model.mapToSource(index)
row_dict = model.sourceModel().row_data(model_index)
row_dict.pop("description", None)
QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view)
return True
class CenterCheckBoxDelegate(DictToolTipDelegate):
"""Custom checkbox delegate to center checkboxes in table cells."""
def __init__(self, parent=None):
super().__init__(parent)
colors = get_accent_colors()
self._icon_checked = material_icon(
"check_box", size=QtCore.QSize(16, 16), color=colors.default
)
self._icon_unchecked = material_icon(
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default
)
def apply_theme(self, theme: str | None = None):
colors = get_accent_colors()
self._icon_checked.setColor(colors.default)
self._icon_unchecked.setColor(colors.default)
def paint(self, painter, option, index):
value = index.model().data(index, QtCore.Qt.CheckStateRole)
if value is None:
super().paint(painter, option, index)
return
# Choose icon based on state
pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked
# Draw icon centered
rect = option.rect
pix_rect = pixmap.rect()
pix_rect.moveCenter(rect.center())
painter.drawPixmap(pix_rect.topLeft(), pixmap)
def editorEvent(self, event, model, option, index):
if event.type() != QtCore.QEvent.MouseButtonRelease:
return False
current = model.data(index, QtCore.Qt.CheckStateRole)
new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked
return model.setData(index, new_state, QtCore.Qt.CheckStateRole)
class WrappingTextDelegate(DictToolTipDelegate):
"""Custom delegate for wrapping text in table cells."""
def paint(self, painter, option, index):
text = index.model().data(index, QtCore.Qt.DisplayRole)
if not text:
return super().paint(painter, option, index)
painter.save()
painter.setClipRect(option.rect)
text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop
painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text)
painter.restore()
def sizeHint(self, option, index):
text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "")
# if not text:
# return super().sizeHint(option, index)
# Use the actual column width
table = index.model().parent() # or store reference to QTableView
column_width = table.columnWidth(index.column()) # - 8
doc = QtGui.QTextDocument()
doc.setDefaultFont(option.font)
doc.setTextWidth(column_width)
doc.setPlainText(text)
layout_height = doc.documentLayout().documentSize().height()
height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off
return QtCore.QSize(column_width, height)
class DeviceTableModel(QtCore.QAbstractTableModel):
"""
Custom Device Table Model for managing device configurations.
Sort logic is implemented directly on the data of the table view.
"""
def __init__(self, device_config: list[dict] | None = None, parent=None):
super().__init__(parent)
self._device_config = device_config or []
self.headers = [
"name",
"deviceClass",
"readoutPriority",
"enabled",
"readOnly",
"deviceTags",
"description",
]
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
###############################################
########## Overwrite custom Qt methods ########
###############################################
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
return len(self._device_config)
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
return len(self.headers)
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal:
return self.headers[section]
return None
def row_data(self, index: QtCore.QModelIndex) -> dict:
"""Return the row data for the given index."""
if not index.isValid():
return {}
return copy.deepcopy(self._device_config[index.row()])
def data(self, index, role=QtCore.Qt.DisplayRole):
"""Return data for the given index and role."""
if not index.isValid():
return None
row, col = index.row(), index.column()
key = self.headers[col]
value = self._device_config[row].get(key)
if role == QtCore.Qt.DisplayRole:
if key in ("enabled", "readOnly"):
return bool(value)
if key == "deviceTags":
return ", ".join(str(tag) for tag in value) if value else ""
return str(value) if value is not None else ""
if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"):
return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
if role == QtCore.Qt.TextAlignmentRole:
if key in ("enabled", "readOnly"):
return QtCore.Qt.AlignCenter
return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
if role == QtCore.Qt.FontRole:
font = QtGui.QFont()
return font
return None
def flags(self, index):
"""Flags for the table model."""
if not index.isValid():
return QtCore.Qt.NoItemFlags
key = self.headers[index.column()]
if key in ("enabled", "readOnly"):
base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
if self._checkable_columns_enabled.get(key, True):
return base_flags | QtCore.Qt.ItemIsUserCheckable
else:
return base_flags # disable editing but still visible
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool:
"""
Method to set the data of the table.
Args:
index (QModelIndex): The index of the item to modify.
value (Any): The new value to set.
role (Qt.ItemDataRole): The role of the data being set.
Returns:
bool: True if the data was set successfully, False otherwise.
"""
if not index.isValid():
return False
key = self.headers[index.column()]
row = index.row()
if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole:
if not self._checkable_columns_enabled.get(key, True):
return False # ignore changes if column is disabled
self._device_config[row][key] = value == QtCore.Qt.Checked
self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole])
return True
return False
####################################
############ Public methods ########
####################################
def get_device_config(self) -> list[dict]:
"""Return the current device config (with checkbox updates applied)."""
return self._device_config
def set_checkbox_enabled(self, column_name: str, enabled: bool):
"""
Enable/Disable the checkbox column.
Args:
column_name (str): The name of the column to modify.
enabled (bool): Whether the checkbox should be enabled or disabled.
"""
if column_name in self._checkable_columns_enabled:
self._checkable_columns_enabled[column_name] = enabled
col = self.headers.index(column_name)
top_left = self.index(0, col)
bottom_right = self.index(self.rowCount() - 1, col)
self.dataChanged.emit(
top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole]
)
def set_device_config(self, device_config: list[dict]):
"""
Replace the device config.
Args:
device_config (list[dict]): The new device config to set.
"""
self.beginResetModel()
self._device_config = list(device_config)
self.endResetModel()
@SafeSlot(dict)
def add_device(self, device: dict):
"""
Add an extra device to the device config at the bottom.
Args:
device (dict): The device configuration to add.
"""
row = len(self._device_config)
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self._device_config.append(device)
self.endInsertRows()
@SafeSlot(int)
def remove_device_by_row(self, row: int):
"""
Remove one device row by index. This maps to the row to the source of the data model
Args:
row (int): The index of the device row to remove.
"""
if 0 <= row < len(self._device_config):
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
self._device_config.pop(row)
self.endRemoveRows()
@SafeSlot(list)
def remove_devices_by_rows(self, rows: list[int]):
"""
Remove multiple device rows by their indices.
Args:
rows (list[int]): The indices of the device rows to remove.
"""
for row in sorted(rows, reverse=True):
self.remove_device_by_row(row)
@SafeSlot(str)
def remove_device_by_name(self, name: str):
"""
Remove one device row by name.
Args:
name (str): The name of the device to remove.
"""
for row, device in enumerate(self._device_config):
if device.get("name") == name:
self.remove_device_by_row(row)
break
class BECTableView(QtWidgets.QTableView):
"""Table View with custom keyPressEvent to delete rows with backspace or delete key"""
def keyPressEvent(self, event) -> None:
"""
Delete selected rows with backspace or delete key
Args:
event: keyPressEvent
"""
if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
return super().keyPressEvent(event)
proxy_indexes = self.selectedIndexes()
if not proxy_indexes:
return
# Get unique rows (proxy indices) in reverse order so removal indexes stay valid
proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True)
# Map to source model rows
source_rows = [
self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows
]
model: DeviceTableModel = self.model().sourceModel() # access underlying model
# Delegate confirmation and removal to helper
removed = self._confirm_and_remove_rows(model, source_rows)
if not removed:
return
def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool:
"""
Prompt the user to confirm removal of rows and remove them from the model if accepted.
Returns True if rows were removed, False otherwise.
"""
cfg = model.get_device_config()
names = [str(cfg[r].get("name", "<unknown>")) for r in sorted(source_rows)]
msg = QtWidgets.QMessageBox(self)
msg.setIcon(QtWidgets.QMessageBox.Warning)
msg.setWindowTitle("Confirm remove devices")
if len(names) == 1:
msg.setText(f"Remove device '{names[0]}'?")
else:
msg.setText(f"Remove {len(names)} devices?")
msg.setInformativeText("\n".join(names))
msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)
msg.setDefaultButton(QtWidgets.QMessageBox.Cancel)
res = msg.exec_()
if res == QtWidgets.QMessageBox.Ok:
model.remove_devices_by_rows(source_rows)
# TODO add signal for removed devices
return True
return False
class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._hidden_rows = set()
self._filter_text = ""
self._enable_fuzzy = True
self._filter_columns = [0, 1] # name and deviceClass for search
def hide_rows(self, row_indices: list[int]):
"""
Hide specific rows in the model.
Args:
row_indices (list[int]): List of row indices to hide.
"""
self._hidden_rows.update(row_indices)
self.invalidateFilter()
def show_rows(self, row_indices: list[int]):
"""
Show specific rows in the model.
Args:
row_indices (list[int]): List of row indices to show.
"""
self._hidden_rows.difference_update(row_indices)
self.invalidateFilter()
def show_all_rows(self):
"""
Show all rows in the model.
"""
self._hidden_rows.clear()
self.invalidateFilter()
@SafeSlot(int)
def disable_fuzzy_search(self, enabled: int):
self._enable_fuzzy = not bool(enabled)
self.invalidateFilter()
def setFilterText(self, text: str):
self._filter_text = text.lower()
self.invalidateFilter()
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
# No hidden rows, and no filter text
if not self._filter_text and not self._hidden_rows:
return True
# Hide hidden rows
if source_row in self._hidden_rows:
return False
# Check the filter text for each row
model = self.sourceModel()
text = self._filter_text.lower()
for column in self._filter_columns:
index = model.index(source_row, column, source_parent)
data = str(model.data(index, QtCore.Qt.DisplayRole) or "")
if self._enable_fuzzy is True:
match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text in data.lower():
return True
return False
class DeviceTableView(BECWidget, QtWidgets.QWidget):
"""Device Table View for the device manager."""
RPC = False
PLUGIN = False
devices_removed = QtCore.Signal(list)
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self.layout = QtWidgets.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(4)
# Setup table view
self._setup_table_view()
# Setup search view, needs table proxy to be iniditate
self._setup_search()
# Add widgets to main layout
self.layout.addLayout(self.search_controls)
self.layout.addWidget(self.table)
def _setup_search(self):
"""Create components related to the search functionality"""
# Create search bar
self.search_layout = QtWidgets.QHBoxLayout()
self.search_label = QtWidgets.QLabel("Search:")
self.search_input = QtWidgets.QLineEdit()
self.search_input.setPlaceholderText(
"Filter devices (approximate matching)..."
) # Default to fuzzy search
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.proxy.setFilterText)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
# Add exact match toggle
self.fuzzy_layout = QtWidgets.QHBoxLayout()
self.fuzzy_label = QtWidgets.QLabel("Exact Match:")
self.fuzzy_is_disabled = QtWidgets.QCheckBox()
self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search)
self.fuzzy_is_disabled.setToolTip(
"Enable approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
self.fuzzy_layout.addWidget(self.fuzzy_label)
self.fuzzy_layout.addWidget(self.fuzzy_is_disabled)
self.fuzzy_layout.addStretch()
# Add both search components to the layout
self.search_controls = QtWidgets.QHBoxLayout()
self.search_controls.addLayout(self.search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(self.fuzzy_layout)
QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
def _setup_table_view(self) -> None:
"""Setup the table view."""
# Model + Proxy
self.table = BECTableView(self)
self.model = DeviceTableModel(parent=self.table)
self.proxy = DeviceFilterProxyModel(parent=self.table)
self.proxy.setSourceModel(self.model)
self.table.setModel(self.proxy)
self.table.setSortingEnabled(True)
# Delegates
self.checkbox_delegate = CenterCheckBoxDelegate(self.table)
self.wrap_delegate = WrappingTextDelegate(self.table)
self.tool_tip_delegate = DictToolTipDelegate(self.table)
self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority
self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled
self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly
self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags
self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description
# Column resize policies
# TODO maybe we need here a flexible header options as deviceClass
# may get quite long for beamlines plugin repos
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly
# TODO maybe better stretch...
header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags
header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description
self.table.setColumnWidth(3, 82)
self.table.setColumnWidth(4, 82)
# Ensure column widths stay fixed
header.setMinimumSectionSize(70)
header.setDefaultSectionSize(90)
# Enable resizing of column
header.sectionResized.connect(self.on_table_resized)
# Selection behavior
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.table.horizontalHeader().setHighlightSections(False)
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
def device_config(self) -> list[dict]:
"""Get the device config."""
return self.model.get_device_config()
def apply_theme(self, theme: str | None = None):
self.checkbox_delegate.apply_theme(theme)
######################################
########### Slot API #################
######################################
@SafeSlot(int, int, int)
def on_table_resized(self, column, old_width, new_width):
"""Handle changes to the table column resizing."""
if column != len(self.model.headers) - 1:
return
for row in range(self.table.model().rowCount()):
index = self.table.model().index(row, column)
delegate = self.table.itemDelegate(index)
option = QtWidgets.QStyleOptionViewItem()
height = delegate.sizeHint(option, index).height()
self.table.setRowHeight(row, height)
######################################
##### Ext. Slot API #################
######################################
@SafeSlot(list)
def set_device_config(self, config: list[dict]):
"""
Set the device config.
Args:
config (list[dict]): The device config to set.
"""
self.model.set_device_config(config)
@SafeSlot()
def clear_device_config(self):
"""
Clear the device config.
"""
self.model.set_device_config([])
@SafeSlot(dict)
def add_device(self, device: dict):
"""
Add a device to the config.
Args:
device (dict): The device to add.
"""
self.model.add_device(device)
@SafeSlot(int)
@SafeSlot(str)
def remove_device(self, dev: int | str):
"""
Remove the device from the config either by row id, or device name.
Args:
dev (int | str): The device to remove, either by row id or device name.
"""
if isinstance(dev, int):
# TODO test this properly, check with proxy index and source index
# Use the proxy model to map to the correct row
model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0))
self.model.remove_device_by_row(model_source_index.row())
return
if isinstance(dev, str):
self.model.remove_device_by_name(dev)
return
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = DeviceTableView()
# pylint: disable=protected-access
config = window.client.device_manager._get_redis_device_config()
window.set_device_config(config)
window.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,4 @@
"""
This module provides an implementation for the device config view.
The widget is the entry point for users to edit device configurations.
"""

View File

@@ -85,7 +85,6 @@ class ScanMetadata(PydanticModelForm):
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
if __name__ == "__main__": # pragma: no cover

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):
"""

View File

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

View File

@@ -42,10 +42,15 @@ class CurveConfig(ConnectionConfig):
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
"solid", description="The style of the pen of the curve."
)
source: Literal["device", "dap", "custom"] = Field(
source: Literal["device", "dap", "custom", "history"] = Field(
"custom", description="The source of the curve."
)
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
scan_id: str | None = Field(None, description="Scan ID to be used when `source` is 'history'.")
scan_number: int | None = Field(
None, description="Scan index to be used when `source` is 'history'."
)
current_x_mode: str | None = Field(None, description="The current x mode of the history curve.")
parent_label: str | None = Field(
None, description="The label of the parent plot, only relevant for dap curves."
)
@@ -199,8 +204,9 @@ class Curve(BECConnector, pg.PlotDataItem):
Raises:
ValueError: If the source is not custom.
"""
if self.config.source == "custom":
if self.config.source in ["custom", "history"]:
self.setData(x, y)
self.parent_item.request_dap_update.emit()
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")

View File

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

View File

@@ -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 ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan 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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from pydantic import ValidationError, field_validator
from pydantic import field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
from qtpy.QtWidgets import (
QApplication,

View File

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

View File

@@ -0,0 +1,146 @@
import datetime
import importlib
import os
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
from bec_widgets.widgets.containers.explorer.explorer import Explorer
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
class IDEExplorer(BECWidget, QWidget):
"""Integrated Development Environment Explorer"""
PLUGIN = True
RPC = False
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._sections = set()
self.main_explorer = Explorer(parent=self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.main_explorer)
self.setLayout(layout)
self.sections = ["scripts"]
@SafeProperty(list)
def sections(self):
return list(self._sections)
@sections.setter
def sections(self, value):
existing_sections = set(self._sections)
self._sections = set(value)
self._update_section_visibility(self._sections - existing_sections)
def _update_section_visibility(self, sections):
for section in sections:
self._add_section(section)
def _add_section(self, section_name):
match section_name.lower():
case "scripts":
self.add_script_section()
case _:
pass
def add_script_section(self):
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
section.expanded = False
script_explorer = Explorer(parent=self)
script_widget = ScriptTreeWidget(parent=self)
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
local_scripts_section.set_widget(script_widget)
local_script_dir = self.client._service_config.model.user_scripts.base_path
if not os.path.exists(local_script_dir):
os.makedirs(local_script_dir)
script_widget.set_directory(local_script_dir)
script_explorer.add_section(local_scripts_section)
section.set_widget(script_explorer)
self.main_explorer.add_section(section)
plugin_scripts_dir = None
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
break
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
return
shared_script_section = CollapsibleSection(title="Shared", parent=self)
shared_script_widget = ScriptTreeWidget(parent=self)
shared_script_section.set_widget(shared_script_widget)
shared_script_widget.set_directory(plugin_scripts_dir)
script_explorer.add_section(shared_script_section)
# macros_section = CollapsibleSection("MACROS", indentation=0)
# macros_section.set_widget(QLabel("Macros will be implemented later"))
# self.main_explorer.add_section(macros_section)
def _add_local_script(self):
"""Show a dialog to enter the name of a new script and create it."""
target_section = self.main_explorer.get_section("SCRIPTS")
script_dir_section = target_section.content_widget.get_section("Local")
local_script_dir = script_dir_section.content_widget.directory
# Prompt user for filename
filename, ok = QInputDialog.getText(
self, "New Script", f"Enter script name ({local_script_dir}/<filename>):"
)
if not ok or not filename:
return # User cancelled or didn't enter a name
# Add .py extension if not already present
if not filename.endswith(".py"):
filename = f"{filename}.py"
file_path = os.path.join(local_script_dir, filename)
# Check if file already exists
if os.path.exists(file_path):
response = QMessageBox.question(
self,
"File exists",
f"The file '{filename}' already exists. Do you want to overwrite it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if response != QMessageBox.StandardButton.Yes:
return # User chose not to overwrite
try:
# Create the file with a basic template
with open(file_path, "w", encoding="utf-8") as f:
f.write(
f"""
\"\"\"
{filename} - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
\"\"\"
"""
)
except Exception as e:
# Show error if file creation failed
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
app = QApplication([])
script_explorer = IDEExplorer()
script_explorer.show()
app.exec_()

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
DOM_XML = """
<ui language='c++'>
<widget class='IDEExplorer' name='ide_explorer'>
</widget>
</ui>
"""
class IDEExplorerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = IDEExplorer(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(IDEExplorer.ICON_NAME)
def includeFile(self):
return "ide_explorer"
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 "IDEExplorer"
def toolTip(self):
return "Integrated Development Environment Explorer"
def whatsThis(self):
return self.toolTip()

View File

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

View 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 1100000
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())

View File

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

View File

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

View File

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

View File

@@ -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 = "__", ""

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

View File

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

View File

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

View File

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

View File

@@ -7,4 +7,5 @@ sphinx-copybutton
sphinx-inline-tabs
myst-parser
sphinx-design
sphinx-autoapi
tomli

View File

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

View File

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

View File

@@ -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()
![BECDockArea.png](BECDockArea.png) -->
## 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
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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:
```
````

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,26 +4,27 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.36.0"
version = "2.45.9"
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",
"qtmonaco~=0.5",
"thefuzz~=0.22",
]
@@ -31,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",
@@ -41,6 +41,7 @@ dev = [
"pytest-cov~=6.1.1",
"watchdog~=6.0",
"pre_commit~=4.2",
]
[project.urls]

View File

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

View File

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

View File

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

View File

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

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