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

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
|
||||
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
|
||||
plugin system.
|
||||
|
||||
## Highlights
|
||||
**⚠️ Important Notice:**
|
||||
|
||||
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
|
||||
a single line of code**. Just launch, drag widgets, and do your experiment.
|
||||
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: drag‑dock, tab,
|
||||
split, and export profiles/workspaces for reuse.
|
||||
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
|
||||
`BECIPythonClient`.
|
||||
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
|
||||
with the custom BEC loader for a zero‑glue workflow.
|
||||
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
|
||||
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
|
||||
acquisition history.
|
||||
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
|
||||
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
|
||||
or override behavior as needed.
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
|
||||
## 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)
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
|
||||
## 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
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
|
||||
### 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://github.com/bec-project/bec_widgets.git
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev]
|
||||
pip install -e .[dev,pyside6]
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Dock area interface: build GUIs in seconds
|
||||
|
||||
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
|
||||
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
|
||||
operate immediately and refine later with RPC or Designer if needed.
|
||||
|
||||

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

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

|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
|
||||
Designer plugin that are immediately usable with your BEC setup. Widgets
|
||||
come online with live BEC/Redis wiring out of the box. 
|
||||
|
||||
<details>
|
||||
<summary> View code: Example Widget </summary>
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
|
||||
class SimpleMotorWidget(BECWidget, QWidget):
|
||||
USER_ACCESS = ["move"]
|
||||
|
||||
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.motor_name = motor_name
|
||||
self.step = float(step)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.value_label = QLabel(f"{self.motor_name}: —")
|
||||
self.btn_left = QPushButton("◀︎ -5")
|
||||
self.btn_right = QPushButton("+5 ▶︎")
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.btn_left)
|
||||
row.addWidget(self.btn_right)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.addWidget(self.value_label)
|
||||
col.addLayout(row)
|
||||
|
||||
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
|
||||
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_readback(self, data: dict, meta: dict):
|
||||
current_value = data.get("signals").get(self.motor_name).get('value')
|
||||
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
|
||||
|
||||
@Slot(str, float)
|
||||
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
|
||||
if direction == "left":
|
||||
self.dev[self.motor_name].move(-step, relative=True)
|
||||
else:
|
||||
self.dev[self.motor_name].move(step, relative=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = SimpleMotorWidget(motor_name="samx", step=5.0)
|
||||
w.setWindowTitle("MotorJogWidget")
|
||||
w.resize(280, 90)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Widget Library
|
||||
|
||||
A large and growing catalog—plug, configure, run:
|
||||
|
||||
### Plotting
|
||||
|
||||
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
|
||||
history data.
|
||||
|
||||
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
|
||||
|
||||
### Scan orchestration and motion control.
|
||||
|
||||
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
|
||||
Positioner boxes and tweak controls handle precise moves, homing, and calibration for day‑to‑day alignment.
|
||||
|
||||
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
|
||||
## 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/).
|
||||
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
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -285,18 +285,6 @@ 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,
|
||||
@@ -455,8 +443,8 @@ class BECGuiClient(RPCBase):
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
@@ -466,24 +454,11 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
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()
|
||||
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()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
|
||||
@@ -202,11 +202,6 @@ 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,
|
||||
@@ -230,12 +225,6 @@ 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,
|
||||
|
||||
@@ -7,8 +7,10 @@ import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
@@ -92,6 +94,11 @@ class GUIServer:
|
||||
Run the GUI server.
|
||||
"""
|
||||
self.app = QApplication(sys.argv)
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
else:
|
||||
apply_theme("light")
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.setup_bec_icon()
|
||||
|
||||
0
bec_widgets/examples/autohide_example/__init__.py
Normal file
0
bec_widgets/examples/autohide_example/__init__.py
Normal file
98
bec_widgets/examples/autohide_example/autohide_overlay.py
Normal file
98
bec_widgets/examples/autohide_example/autohide_overlay.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from typing import List
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class AutoHideOverlay(QWidget):
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
# IMPORTANT, if you decide to use autohide, you must set these flags before creating ANY CDockManager, it has to be put into inity, let me know and I can enforce it
|
||||
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
|
||||
CDockManager.setAutoHideConfigFlag(
|
||||
CDockManager.DockAreaHasAutoHideButton, False
|
||||
) # to not have everywhere these buttons
|
||||
self.dock_manager = CDockManager(self)
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Initialize the widgets
|
||||
self.left_widget = QWidget(self)
|
||||
self.left_widget.layout = QVBoxLayout(self.left_widget)
|
||||
self.auto_hide_controls = QPushButton("Auto Hide Controls", self.left_widget)
|
||||
self.auto_hide_controls.setCheckable(True)
|
||||
self.tree_widget = QTreeWidget(self)
|
||||
self.left_widget.layout.addWidget(self.auto_hide_controls)
|
||||
self.left_widget.layout.addWidget(self.tree_widget)
|
||||
|
||||
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
|
||||
# table with some data
|
||||
self.table = QTableWidget(10, 3, self)
|
||||
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
|
||||
for row in range(10):
|
||||
for col in range(3):
|
||||
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
|
||||
|
||||
self.list_widget = QListWidget(self)
|
||||
for i in range(10):
|
||||
self.list_widget.addItem(f"List Item {i+1}")
|
||||
|
||||
# Create the dock widgets
|
||||
self.tree_dock = QtAds.CDockWidget("Explorer", self)
|
||||
self.tree_dock.setWidget(self.left_widget)
|
||||
|
||||
self.table_dock = QtAds.CDockWidget("Table", self)
|
||||
self.table_dock.setWidget(self.table)
|
||||
|
||||
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
|
||||
self.plotting_ads_dock.setWidget(self.plotting_ads)
|
||||
# this one will be autohide one
|
||||
self.list_dock = QtAds.CDockWidget("List Widget", self)
|
||||
self.list_dock.setWidget(self.list_widget)
|
||||
|
||||
# Monaco will be central widget
|
||||
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
|
||||
|
||||
# Add the dock widgets to the dock manager
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
|
||||
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
|
||||
QtAds.SideBarRight, self.list_dock
|
||||
)
|
||||
self.autohide_container.setSize(350)
|
||||
|
||||
# Connect signals
|
||||
self.auto_hide_controls.toggled.connect(self.toggle_auto_hide)
|
||||
|
||||
def toggle_auto_hide(self, checked: bool):
|
||||
# start as collapsed, implement better logic
|
||||
# self.autohide_container.collapseView(checked)
|
||||
|
||||
# or this if you just want toggle
|
||||
self.autohide_container.toggleCollapseState()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
auto_hide_overlay = AutoHideOverlay()
|
||||
auto_hide_overlay.show()
|
||||
auto_hide_overlay.resize(1200, 800)
|
||||
|
||||
sys.exit(app.exec())
|
||||
123
bec_widgets/examples/autohide_example/autohide_push.py
Normal file
123
bec_widgets/examples/autohide_example/autohide_push.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from typing import Optional
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class AutoHidePush(QWidget):
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
# CDockManager.setConfigFlag(CDockManager.FocusHighlighting, True)
|
||||
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
|
||||
# CDockManager.setAutoHideConfigFlag(CDockManager.AutoHideShowOnMouseOver, True)
|
||||
self.dock_manager = CDockManager(self)
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Initialize the widgets
|
||||
self.left_widget = QWidget(self)
|
||||
self.left_widget.layout = QVBoxLayout(self.left_widget)
|
||||
self.push_mode_btn = QPushButton(self.left_widget)
|
||||
self.push_mode_btn.setCheckable(True)
|
||||
self.push_mode_btn.setText("Pin (show and push)")
|
||||
self.push_mode_btn.setChecked(False)
|
||||
self.tree_widget = QTreeWidget(self)
|
||||
self.left_widget.layout.addWidget(self.push_mode_btn)
|
||||
self.left_widget.layout.addWidget(self.tree_widget)
|
||||
|
||||
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
|
||||
# table with some data
|
||||
self.table = QTableWidget(10, 3, self)
|
||||
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
|
||||
for row in range(10):
|
||||
for col in range(3):
|
||||
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
|
||||
|
||||
self.list_widget = QListWidget(self)
|
||||
for i in range(10):
|
||||
self.list_widget.addItem(f"List Item {i+1}")
|
||||
|
||||
# Create the dock widgets
|
||||
self.tree_dock = QtAds.CDockWidget("Explorer", self)
|
||||
self.tree_dock.setWidget(self.left_widget)
|
||||
|
||||
self.table_dock = QtAds.CDockWidget("Table", self)
|
||||
self.table_dock.setWidget(self.table)
|
||||
|
||||
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
|
||||
self.plotting_ads_dock.setWidget(self.plotting_ads)
|
||||
# this one will be autohide one
|
||||
self.list_dock = QtAds.CDockWidget("List Widget", self)
|
||||
self.list_dock.setWidget(self.list_widget)
|
||||
|
||||
# Monaco will be central widget
|
||||
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
|
||||
|
||||
# Add the dock widgets to the dock manager
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
|
||||
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
|
||||
QtAds.SideBarRight, self.list_dock
|
||||
)
|
||||
self.autohide_container.setSize(350)
|
||||
self._last_side = QtAds.SideBarRight
|
||||
# Ensure auto-hide starts collapsed and button text is correct
|
||||
self.autohide_container.collapseView(True)
|
||||
self.push_mode_btn.setText("Pin (show and push)")
|
||||
|
||||
# Connect signals
|
||||
self.push_mode_btn.toggled.connect(self.toggle_pin_mode)
|
||||
|
||||
def toggle_pin_mode(self, pinned: bool):
|
||||
# pinned=True -> convert auto-hide overlay into a normal dock (push layout) and show it
|
||||
if pinned:
|
||||
if self.autohide_container is not None:
|
||||
# Remember the current side (edge) to restore when unpinning
|
||||
if hasattr(self.autohide_container, "sideBarLocation"):
|
||||
self._last_side = self.autohide_container.sideBarLocation()
|
||||
# Move contents back into the dock container (this deletes the auto-hide container)
|
||||
self.autohide_container.moveContentsToParent()
|
||||
self.autohide_container = None
|
||||
# Ensure the dock is visible when pinned
|
||||
self.list_dock.show()
|
||||
self.push_mode_btn.setText("Unpin (send to sidebar)")
|
||||
else:
|
||||
# Convert the pinned dock back into an auto-hide overlay at the last used side and collapse it
|
||||
side = getattr(self, "_last_side", QtAds.SideBarRight)
|
||||
container = self.dock_manager.addAutoHideDockWidget(side, self.list_dock)
|
||||
# Preserve a sensible size from the current dock widget geometry
|
||||
if side in (QtAds.SideBarLeft, QtAds.SideBarRight):
|
||||
target_size = max(200, min(self.list_dock.width(), 600))
|
||||
else:
|
||||
target_size = max(200, min(self.list_dock.height(), 600))
|
||||
container.setSize(int(target_size))
|
||||
self.autohide_container = container
|
||||
# Collapse so it disappears to the side tab
|
||||
self.autohide_container.collapseView(True)
|
||||
self.push_mode_btn.setText("Pin (show and push)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
auto_hide_overlay = AutoHidePush()
|
||||
auto_hide_overlay.show()
|
||||
auto_hide_overlay.resize(1200, 800)
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -15,7 +15,9 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
@@ -44,6 +46,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
"ads": self.ads,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
@@ -55,7 +58,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,11 +108,12 @@ 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")
|
||||
# fifth_tab = QWidget()
|
||||
# fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
# self.wf = Waveform()
|
||||
# fifth_tab_layout.addWidget(self.wf)
|
||||
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
# tab_widget.setCurrentIndex(4)
|
||||
#
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
@@ -119,14 +123,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(1)
|
||||
#
|
||||
# seventh_tab = QWidget()
|
||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
# self.scatter = ScatterWaveform()
|
||||
# self.scatter_mi = self.scatter.main_curve
|
||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
||||
# seventh_tab_layout.addWidget(self.scatter)
|
||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
# tab_widget.setCurrentIndex(6)
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.ads = AdvancedDockArea(gui_id="ads")
|
||||
seventh_tab_layout.addWidget(self.ads)
|
||||
tab_widget.addTab(seventh_tab, "ADS")
|
||||
tab_widget.setCurrentIndex(2)
|
||||
#
|
||||
# eighth_tab = QWidget()
|
||||
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
@@ -168,6 +170,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
|
||||
@@ -173,7 +173,7 @@ class FakePositioner(BECPositioner):
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self, cached=False):
|
||||
def read(self):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
|
||||
@@ -77,6 +77,8 @@ class BECConnector:
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
widget_removed = Signal()
|
||||
name_established = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -204,6 +206,10 @@ class BECConnector:
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
@@ -213,7 +219,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.sendPostedEvents()
|
||||
QApplication.processEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
@@ -450,6 +456,7 @@ class BECConnector:
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
import PySide6QtAds as QtAds
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
@@ -11,9 +11,9 @@ from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
@@ -27,7 +27,7 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
USER_ACCESS = ["remove", "attach", "detach"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
@@ -45,8 +45,7 @@ class BECWidget(BECConnector):
|
||||
|
||||
>>> class MyWidget(BECWidget, QWidget):
|
||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
>>> super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
>>> QWidget.__init__(self, parent=parent)
|
||||
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||
|
||||
|
||||
Args:
|
||||
@@ -62,15 +61,6 @@ class BECWidget(BECConnector):
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
# Instead, we will set the theme to the system setting on startup
|
||||
if darkdetect.isDark():
|
||||
set_theme("dark")
|
||||
else:
|
||||
set_theme("light")
|
||||
|
||||
if theme_update:
|
||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||
self._connect_to_theme_change()
|
||||
@@ -78,9 +68,11 @@ class BECWidget(BECConnector):
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
if hasattr(qapp, "theme"):
|
||||
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -124,6 +116,26 @@ class BECWidget(BECConnector):
|
||||
screenshot.save(file_name)
|
||||
logger.info(f"Screenshot saved to {file_name}")
|
||||
|
||||
def attach(self):
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
|
||||
if not dock.isFloating():
|
||||
return
|
||||
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
if dock.isFloating():
|
||||
return
|
||||
dock.setFloating()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
|
||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import bec_qthemes
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -23,7 +23,10 @@ def get_theme_name():
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
return bec_qthemes.load_palette(get_theme_name())
|
||||
# FIXME this is legacy code, should be removed in the future
|
||||
app = QApplication.instance()
|
||||
palette = app.palette()
|
||||
return palette
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
@@ -36,105 +39,18 @@ def get_accent_colors() -> AccentColors | None:
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
def _theme_update_callback():
|
||||
"""
|
||||
Internal callback function to update the theme based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# pylint: disable=protected-access
|
||||
app.theme.theme = app.os_listener._theme.lower()
|
||||
app.theme_signal.theme_updated.emit(app.theme.theme)
|
||||
apply_theme(app.os_listener._theme.lower())
|
||||
|
||||
|
||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||
"""
|
||||
Set the theme for the application.
|
||||
|
||||
Args:
|
||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
||||
|
||||
app.theme_signal.theme_updated.emit(theme)
|
||||
apply_theme(theme)
|
||||
|
||||
if theme != "auto":
|
||||
return
|
||||
|
||||
if not hasattr(app, "os_listener") or app.os_listener is None:
|
||||
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
||||
app.installEventFilter(app.os_listener)
|
||||
def process_all_deferred_deletes(qapp):
|
||||
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
qapp.processEvents(QEventLoop.AllEvents)
|
||||
|
||||
|
||||
def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
graphic_layouts = [
|
||||
child
|
||||
for top in app.topLevelWidgets()
|
||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
||||
]
|
||||
|
||||
plot_items = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.PlotItem)
|
||||
]
|
||||
|
||||
histograms = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.HistogramLUTItem)
|
||||
]
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
foreground_color = "#141414"
|
||||
label_color = "#000000"
|
||||
axis_color = "#666666"
|
||||
else:
|
||||
background_color = "#141414" # Dark mode
|
||||
foreground_color = "#e9ecef"
|
||||
label_color = "#FFFFFF"
|
||||
axis_color = "#CCCCCC"
|
||||
|
||||
# update GraphicsLayoutWidget
|
||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
||||
for pg_widget in graphic_layouts:
|
||||
pg_widget.setBackground(background_color)
|
||||
|
||||
# update PlotItems
|
||||
for plot_item in plot_items:
|
||||
for axis in ["left", "right", "top", "bottom"]:
|
||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# Change title color
|
||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
||||
|
||||
# Change legend color
|
||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
||||
plot_item.legend.setLabelTextColor(label_color)
|
||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
||||
for sample, label in plot_item.legend.items:
|
||||
label_text = label.text
|
||||
label.setText(label_text, color=label_color)
|
||||
|
||||
# update HistogramLUTItem
|
||||
for histogram in histograms:
|
||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
app.setStyleSheet(style)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
apply_theme_global(theme)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@@ -11,6 +11,7 @@ from qtpy.QtWidgets import (
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -122,15 +123,14 @@ class CompactPopupWidget(QWidget):
|
||||
self.compact_view_widget = QWidget(self)
|
||||
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
QHBoxLayout(self.compact_view_widget)
|
||||
self.compact_view_widget.layout().setSpacing(0)
|
||||
self.compact_view_widget.layout().setSpacing(5)
|
||||
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.compact_view_widget.layout().addSpacerItem(
|
||||
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
)
|
||||
self.compact_label = QLabel(self.compact_view_widget)
|
||||
self.compact_status = LedLabel(self.compact_view_widget)
|
||||
self.compact_show_popup = QPushButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setFlat(True)
|
||||
self.compact_show_popup = QToolButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
|
||||
@@ -209,11 +209,8 @@ 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]]
|
||||
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
|
||||
else:
|
||||
# Handle all curves
|
||||
self.items = self.plot_item.items
|
||||
|
||||
# Create or update markers
|
||||
|
||||
@@ -2,7 +2,9 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
@@ -90,6 +92,52 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
return decorator
|
||||
|
||||
|
||||
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
|
||||
"""Internal function used by SafeConnect to handle weak references to slots."""
|
||||
instance = weak_instance()
|
||||
slot_func = weak_slot()
|
||||
|
||||
# Check if the python object has already been garbage collected
|
||||
if instance is None or slot_func is None:
|
||||
return
|
||||
|
||||
# Check if the python object has already been marked for deletion
|
||||
if getattr(instance, "_destroyed", False):
|
||||
return
|
||||
|
||||
# Check if the C++ object is still valid
|
||||
if not shiboken6.isValid(instance):
|
||||
return
|
||||
|
||||
if connect_args:
|
||||
slot_func(*connect_args)
|
||||
slot_func()
|
||||
|
||||
|
||||
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to safely handle Qt signal-slot connections. The python object is only forwarded
|
||||
as a weak reference to avoid stale objects.
|
||||
|
||||
Args:
|
||||
instance: The instance to connect.
|
||||
signal: The signal to connect to.
|
||||
slot: The slot to connect.
|
||||
|
||||
Example:
|
||||
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
"""
|
||||
weak_instance = safe_ref(instance)
|
||||
weak_slot = safe_ref(slot)
|
||||
|
||||
# Create a partial function that will check weak references before calling the actual slot
|
||||
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
|
||||
|
||||
# Connect the signal to the safe connect slot wrapper
|
||||
return signal.connect(safe_slot)
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
|
||||
@@ -81,11 +81,10 @@ 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
|
||||
@@ -106,11 +105,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(parent=self._form_grid, text=item.name)
|
||||
label = QLabel(item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
@@ -129,17 +128,19 @@ class TypedForm(BECWidget, QWidget):
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
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()
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.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):
|
||||
@@ -148,7 +149,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
|
||||
@@ -4,17 +4,7 @@ import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import (
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
OrderedDict,
|
||||
TypeVar,
|
||||
get_args,
|
||||
)
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -209,12 +199,6 @@ class DynamicFormItem(QWidget):
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
def teardown(self):
|
||||
self._layout.deleteLater()
|
||||
self._layout.removeWidget(self._main_widget)
|
||||
self._main_widget.deleteLater()
|
||||
self._main_widget = None
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
@@ -366,13 +350,11 @@ class DictFormItem(DynamicFormItem):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
_IW = TypeVar("_IW", bound=int | float | str)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
|
||||
item: type[_IW]
|
||||
class _ItemAndWidgetType(NamedTuple):
|
||||
# TODO: this should be generic but not supported in 3.10
|
||||
item: type[int | float | str]
|
||||
widget: type[QWidget]
|
||||
default: _IW
|
||||
default: int | float | str
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property
|
||||
from qtpy.QtCore import Property, Qt
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class RoundedFrame(QFrame):
|
||||
# TODO this should be removed completely in favor of QSS styling, no time now
|
||||
"""
|
||||
A custom QFrame with rounded corners and optional theme updates.
|
||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||
@@ -28,6 +29,9 @@ class RoundedFrame(QFrame):
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("roundedFrame")
|
||||
|
||||
# Ensure QSS can paint background/border on this widget
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
# Create a layout for the frame
|
||||
if orientation == "vertical":
|
||||
self.layout = QVBoxLayout(self)
|
||||
@@ -45,22 +49,10 @@ class RoundedFrame(QFrame):
|
||||
|
||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||
self.apply_plot_widget_style()
|
||||
self.update_style()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the frame and its content if theme updates are enabled.
|
||||
"""
|
||||
if self.content_widget is not None and isinstance(
|
||||
self.content_widget, pg.GraphicsLayoutWidget
|
||||
):
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
self.background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
else:
|
||||
self.background_color = "#141414" # Dark mode
|
||||
|
||||
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
|
||||
self.update_style()
|
||||
|
||||
@Property(int)
|
||||
@@ -77,34 +69,21 @@ class RoundedFrame(QFrame):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
if self.background_color:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
background-color: {self.background_color};
|
||||
border-radius: {self._radius}; /* Rounded corners */
|
||||
border-radius: {self._radius}px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
"""
|
||||
Automatically apply background, border, and axis styles to the PlotWidget.
|
||||
|
||||
Args:
|
||||
border (str): Border style (e.g., 'none', '1px solid red').
|
||||
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
|
||||
"""
|
||||
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
||||
# Apply border style via stylesheet
|
||||
self.content_widget.setStyleSheet(
|
||||
f"""
|
||||
GraphicsLayoutWidget {{
|
||||
border: {border}; /* Explicitly set the border */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
self.content_widget.setStyleSheet("")
|
||||
|
||||
|
||||
class ExampleApp(QWidget): # pragma: no cover
|
||||
@@ -128,24 +107,14 @@ class ExampleApp(QWidget): # pragma: no cover
|
||||
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
||||
plot2.plot_item = plot_item_2
|
||||
|
||||
# Wrap PlotWidgets in RoundedFrame
|
||||
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
|
||||
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
|
||||
|
||||
# Add to layout
|
||||
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
|
||||
layout.addWidget(dark_button)
|
||||
layout.addWidget(rounded_plot1)
|
||||
layout.addWidget(rounded_plot2)
|
||||
layout.addWidget(plot1)
|
||||
layout.addWidget(plot2)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
def change_theme():
|
||||
rounded_plot1.apply_theme("light")
|
||||
rounded_plot2.apply_theme("dark")
|
||||
|
||||
QTimer.singleShot(100, change_theme)
|
||||
# Theme flip demo removed; global theming applies automatically
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -11,7 +10,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 Qt, QTimer
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -129,44 +128,16 @@ 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}")
|
||||
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:
|
||||
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
|
||||
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:
|
||||
res = method_obj(*args, **kwargs)
|
||||
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]
|
||||
@@ -258,8 +229,6 @@ class RPCServer:
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
logger.info(f"Waiting for {connector} to be registered...")
|
||||
time.sleep(0.1)
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
from typing import Type
|
||||
|
||||
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 msgpack.is_registered(QPointF):
|
||||
msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)
|
||||
msgpack.register_codec(QPointFEncoder)
|
||||
|
||||
|
||||
class QPointFEncoder(BECCodec):
|
||||
obj_type: Type = QPointF
|
||||
|
||||
@staticmethod
|
||||
def encode(obj: QPointF) -> str:
|
||||
"""
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
@@ -446,6 +446,8 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
button = QToolButton(toolbar)
|
||||
button.setObjectName("toolbarMenuButton")
|
||||
button.setAutoRaise(True)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
|
||||
@@ -10,7 +10,7 @@ from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
@@ -507,7 +507,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
self.test_label.setText("FPS Monitor Disabled")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -465,13 +465,19 @@ class WidgetHierarchy:
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
parent = widget.parent()
|
||||
|
||||
# Retrieve first parent
|
||||
parent = widget.parent() if hasattr(widget, "parent") else None
|
||||
# Walk up, validating each step
|
||||
while parent is not None:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -553,6 +559,64 @@ class WidgetHierarchy:
|
||||
WidgetIO.set_value(child, value)
|
||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||
|
||||
@staticmethod
|
||||
def get_bec_connectors_from_parent(widget) -> list:
|
||||
"""
|
||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||
including the widget itself if it is a BECConnector.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
connectors: list[BECConnector] = []
|
||||
if isinstance(widget, BECConnector):
|
||||
connectors.append(widget)
|
||||
for child in widget.findChildren(BECConnector):
|
||||
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||
connectors.append(child)
|
||||
return connectors
|
||||
|
||||
@staticmethod
|
||||
def find_ancestor(widget, ancestor_class) -> QWidget | None:
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest ancestor matching ancestor_class.
|
||||
ancestor_class may be a class or a class-name string.
|
||||
Returns the matching ancestor, or None if none is found.
|
||||
"""
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
|
||||
# If searching for BECConnector specifically, reuse the dedicated helper
|
||||
try:
|
||||
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
||||
|
||||
if ancestor_class is BECConnector or (
|
||||
isinstance(ancestor_class, str) and ancestor_class == "BECConnector"
|
||||
):
|
||||
return WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
except Exception:
|
||||
# If import fails, fall back to generic traversal below
|
||||
pass
|
||||
|
||||
# Generic traversal across QObject parent chain
|
||||
parent = getattr(widget, "parent", None)
|
||||
if callable(parent):
|
||||
parent = parent()
|
||||
while parent is not None:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
try:
|
||||
if isinstance(ancestor_class, str):
|
||||
if parent.__class__.__name__ == ancestor_class:
|
||||
return parent
|
||||
else:
|
||||
if isinstance(parent, ancestor_class):
|
||||
return parent
|
||||
except Exception:
|
||||
pass
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
|
||||
# Example usage
|
||||
def hierarchy_example(): # pragma: no cover
|
||||
|
||||
@@ -15,6 +15,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -29,43 +31,58 @@ class WidgetStateManager:
|
||||
def __init__(self, widget):
|
||||
self.widget = widget
|
||||
|
||||
def save_state(self, filename: str = None):
|
||||
def save_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||
"""
|
||||
Save the state of the widget to an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to save the state to.
|
||||
settings(QSettings): Optional QSettings object to save the state to.
|
||||
"""
|
||||
if not filename:
|
||||
if not filename and not settings:
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
elif settings:
|
||||
# If settings are provided, save the state to the provided QSettings object
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
else:
|
||||
logger.warning("No filename or settings provided for saving state.")
|
||||
|
||||
def load_state(self, filename: str = None):
|
||||
def load_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||
"""
|
||||
Load the state of the widget from an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to load the state from.
|
||||
settings(QSettings): Optional QSettings object to load the state from.
|
||||
"""
|
||||
if not filename:
|
||||
if not filename and not settings:
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
elif settings:
|
||||
# If settings are provided, load the state from the provided QSettings object
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
else:
|
||||
logger.warning("No filename or settings provided for saving state.")
|
||||
|
||||
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
def _save_widget_state_qsettings(
|
||||
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||
):
|
||||
"""
|
||||
Save the state of the widget to QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to save the state for.
|
||||
settings(QSettings): The QSettings object to save the state to.
|
||||
recursive(bool): Whether to recursively save the state of child widgets.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
@@ -88,21 +105,32 @@ class WidgetStateManager:
|
||||
settings.endGroup()
|
||||
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
if not recursive:
|
||||
return
|
||||
|
||||
direct_children = widget.children()
|
||||
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||
all_children = list(
|
||||
set(direct_children) | set(bec_connector_children)
|
||||
) # to avoid duplicates
|
||||
for child in all_children:
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._save_widget_state_qsettings(child, settings)
|
||||
self._save_widget_state_qsettings(child, settings, False)
|
||||
|
||||
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
def _load_widget_state_qsettings(
|
||||
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||
):
|
||||
"""
|
||||
Load the state of the widget from QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to load the state for.
|
||||
settings(QSettings): The QSettings object to load the state from.
|
||||
recursive(bool): Whether to recursively load the state of child widgets.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
@@ -118,14 +146,21 @@ class WidgetStateManager:
|
||||
widget.setProperty(name, value)
|
||||
settings.endGroup()
|
||||
|
||||
if not recursive:
|
||||
return
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
direct_children = widget.children()
|
||||
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||
all_children = list(
|
||||
set(direct_children) | set(bec_connector_children)
|
||||
) # to avoid duplicates
|
||||
for child in all_children:
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._load_widget_state_qsettings(child, settings)
|
||||
self._load_widget_state_qsettings(child, settings, False)
|
||||
|
||||
def _get_full_widget_name(self, widget: QWidget):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal, cast
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from shiboken6 import isValid
|
||||
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
is_profile_readonly,
|
||||
list_profiles,
|
||||
open_settings,
|
||||
profile_path,
|
||||
read_manifest,
|
||||
set_profile_readonly,
|
||||
write_manifest,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
|
||||
WorkspaceConnection,
|
||||
workspace_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class DockSettingsDialog(QDialog):
|
||||
|
||||
def __init__(self, parent: QWidget, target: QWidget):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Dock Settings")
|
||||
self.setModal(True)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Property editor
|
||||
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
|
||||
layout.addWidget(self.prop_editor)
|
||||
|
||||
|
||||
class SaveProfileDialog(QDialog):
|
||||
"""Dialog for saving workspace profiles with read-only option."""
|
||||
|
||||
def __init__(self, parent: QWidget, current_name: str = ""):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Save Workspace Profile")
|
||||
self.setModal(True)
|
||||
self.resize(400, 150)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Name input
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Profile Name:"))
|
||||
self.name_edit = QLineEdit(current_name)
|
||||
self.name_edit.setPlaceholderText("Enter profile name...")
|
||||
name_row.addWidget(self.name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
# Read-only checkbox
|
||||
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
|
||||
layout.addWidget(self.readonly_checkbox)
|
||||
|
||||
# Info label
|
||||
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
|
||||
info_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Buttons
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch(1)
|
||||
self.save_btn = QPushButton("Save")
|
||||
self.save_btn.setDefault(True)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
self.save_btn.clicked.connect(self.accept)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(self.save_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# Enable/disable save button based on name input
|
||||
self.name_edit.textChanged.connect(self._update_save_button)
|
||||
self._update_save_button()
|
||||
|
||||
def _update_save_button(self):
|
||||
"""Enable save button only when name is not empty."""
|
||||
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
|
||||
|
||||
def get_profile_name(self) -> str:
|
||||
"""Get the entered profile name."""
|
||||
return self.name_edit.text().strip()
|
||||
|
||||
def is_readonly(self) -> bool:
|
||||
"""Check if the profile should be marked as read-only."""
|
||||
return self.readonly_checkbox.isChecked()
|
||||
|
||||
|
||||
class AdvancedDockArea(BECWidget, QWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = [
|
||||
"new",
|
||||
"widget_map",
|
||||
"widget_list",
|
||||
"lock_workspace",
|
||||
"attach_all",
|
||||
"delete_all",
|
||||
"mode",
|
||||
"mode.setter",
|
||||
]
|
||||
|
||||
# Define a signal for mode changes
|
||||
mode_changed = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
mode: str = "developer",
|
||||
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Title (as a top-level QWidget it can have a window title)
|
||||
self.setWindowTitle("Advanced Dock Area")
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
|
||||
# Init Dock Manager
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
|
||||
# Dock manager helper variables
|
||||
self._locked = False # Lock state of the workspace
|
||||
|
||||
# Initialize mode property first (before toolbar setup)
|
||||
self._mode = "developer"
|
||||
self._default_add_direction = (
|
||||
default_add_direction
|
||||
if default_add_direction in ("left", "right", "top", "bottom")
|
||||
else "right"
|
||||
)
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self._setup_toolbar()
|
||||
self._hook_toolbar()
|
||||
|
||||
# Place toolbar and dock manager into layout
|
||||
self._root_layout.addWidget(self.toolbar)
|
||||
self._root_layout.addWidget(self.dock_manager, 1)
|
||||
|
||||
# Populate and hook the workspace combo
|
||||
self._refresh_workspace_list()
|
||||
|
||||
# State manager
|
||||
self.state_manager = WidgetStateManager(self)
|
||||
|
||||
# Developer mode state
|
||||
self._editable = None
|
||||
# Initialize default editable state based on current lock
|
||||
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||
|
||||
# Sync Developer toggle icon state after initial setup
|
||||
dev_action = self.toolbar.components.get_action("developer_mode").action
|
||||
dev_action.setChecked(self._editable)
|
||||
|
||||
# Apply the requested mode after everything is set up
|
||||
self.mode = mode
|
||||
|
||||
def _make_dock(
|
||||
self,
|
||||
widget: QWidget,
|
||||
*,
|
||||
closable: bool,
|
||||
floatable: bool,
|
||||
movable: bool = True,
|
||||
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
start_floating: bool = False,
|
||||
) -> CDockWidget:
|
||||
dock = CDockWidget(widget.objectName())
|
||||
dock.setWidget(widget)
|
||||
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
|
||||
dock.setFeature(CDockWidget.CustomCloseHandling, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
|
||||
|
||||
self._install_dock_settings_action(dock, widget)
|
||||
|
||||
def on_dock_close():
|
||||
widget.close()
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
def on_widget_destroyed():
|
||||
if not isValid(dock):
|
||||
return
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
dock.closeRequested.connect(on_dock_close)
|
||||
if hasattr(widget, "widget_removed"):
|
||||
widget.widget_removed.connect(on_widget_destroyed)
|
||||
|
||||
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
|
||||
self.dock_manager.addDockWidget(area, dock)
|
||||
if start_floating:
|
||||
dock.setFloating()
|
||||
return dock
|
||||
|
||||
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
action = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
|
||||
).action
|
||||
action.setToolTip("Dock settings")
|
||||
action.setObjectName("dockSettingsAction")
|
||||
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
|
||||
dock.setTitleBarActions([action])
|
||||
dock.setting_action = action
|
||||
|
||||
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
dlg = DockSettingsDialog(self, widget)
|
||||
dlg.resize(600, 600)
|
||||
dlg.exec()
|
||||
|
||||
def _apply_dock_lock(self, locked: bool) -> None:
|
||||
if locked:
|
||||
self.dock_manager.lockDockWidgetFeaturesGlobally()
|
||||
else:
|
||||
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
|
||||
|
||||
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||
w = dock.widget()
|
||||
if w and isValid(w):
|
||||
w.close()
|
||||
w.deleteLater()
|
||||
if isValid(dock):
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
|
||||
"""Return ADS DockWidgetArea from a human-friendly direction string.
|
||||
If *where* is None, fall back to instance default.
|
||||
"""
|
||||
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
|
||||
mapping = {
|
||||
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
|
||||
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
|
||||
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
|
||||
}
|
||||
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
|
||||
|
||||
################################################################################
|
||||
# Toolbar Setup
|
||||
################################################################################
|
||||
|
||||
def _setup_toolbar(self):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
|
||||
PLOT_ACTIONS = {
|
||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||
"scatter_waveform": (
|
||||
ScatterWaveform.ICON_NAME,
|
||||
"Add Scatter Waveform",
|
||||
"ScatterWaveform",
|
||||
),
|
||||
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
|
||||
"image": (Image.ICON_NAME, "Add Image", "Image"),
|
||||
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
||||
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
||||
}
|
||||
DEVICE_ACTIONS = {
|
||||
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
|
||||
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
|
||||
}
|
||||
UTIL_ACTIONS = {
|
||||
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
|
||||
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
|
||||
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
|
||||
"progress_bar": (
|
||||
RingProgressBar.ICON_NAME,
|
||||
"Add Circular ProgressBar",
|
||||
"RingProgressBar",
|
||||
),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
self.toolbar.components.add_safe(
|
||||
key,
|
||||
ExpandableMenuAction(
|
||||
label=label,
|
||||
actions={
|
||||
k: MaterialIconAction(
|
||||
icon_name=v[0], tooltip=v[1], filled=True, parent=self
|
||||
)
|
||||
for k, v in mapping.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
b = ToolbarBundle(key, self.toolbar.components)
|
||||
b.add_action(key)
|
||||
self.toolbar.add_bundle(b)
|
||||
|
||||
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
|
||||
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
|
||||
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
|
||||
|
||||
# Create flat toolbar bundles for each widget type
|
||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||
|
||||
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
|
||||
# Create individual action for each widget type
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
self.toolbar.components.add_safe(
|
||||
flat_action_id,
|
||||
MaterialIconAction(
|
||||
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
|
||||
),
|
||||
)
|
||||
bundle.add_action(flat_action_id)
|
||||
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
_build_flat_bundles("plots", PLOT_ACTIONS)
|
||||
_build_flat_bundles("devices", DEVICE_ACTIONS)
|
||||
_build_flat_bundles("utils", UTIL_ACTIONS)
|
||||
|
||||
# Workspace
|
||||
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
|
||||
spacer = QWidget(parent=self.toolbar.components.toolbar)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
spacer_bundle.add_action("spacer")
|
||||
self.toolbar.add_bundle(spacer_bundle)
|
||||
|
||||
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
|
||||
)
|
||||
|
||||
# Dock actions
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"screenshot",
|
||||
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
|
||||
)
|
||||
# Developer mode toggle (moved from menu into toolbar)
|
||||
self.toolbar.components.add_safe(
|
||||
"developer_mode",
|
||||
MaterialIconAction(
|
||||
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
bda = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bda.add_action("attach_all")
|
||||
bda.add_action("screenshot")
|
||||
bda.add_action("dark_mode")
|
||||
bda.add_action("developer_mode")
|
||||
self.toolbar.add_bundle(bda)
|
||||
|
||||
# Default bundle configuration (show menus by default)
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
|
||||
# Store mappings on self for use in _hook_toolbar
|
||||
self._ACTION_MAPPINGS = {
|
||||
"menu_plots": PLOT_ACTIONS,
|
||||
"menu_devices": DEVICE_ACTIONS,
|
||||
"menu_utils": UTIL_ACTIONS,
|
||||
}
|
||||
|
||||
def _hook_toolbar(self):
|
||||
|
||||
def _connect_menu(menu_key: str):
|
||||
menu = self.toolbar.components.get_action(menu_key)
|
||||
mapping = self._ACTION_MAPPINGS[menu_key]
|
||||
for key, (_, _, widget_type) in mapping.items():
|
||||
act = menu.actions[key].action
|
||||
if widget_type == "LogPanel":
|
||||
act.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_menu("menu_plots")
|
||||
_connect_menu("menu_devices")
|
||||
_connect_menu("menu_utils")
|
||||
|
||||
# Connect flat toolbar actions
|
||||
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
for action_id, (_, _, widget_type) in mapping.items():
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||
if widget_type == "LogPanel":
|
||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
|
||||
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
|
||||
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
|
||||
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
# Developer mode toggle
|
||||
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
|
||||
self._on_developer_mode_toggled
|
||||
)
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
self.lock_workspace = not editable
|
||||
self._editable = editable
|
||||
|
||||
# Sync the toolbar lock toggle with current mode
|
||||
lock_action = self.toolbar.components.get_action("lock").action
|
||||
lock_action.setChecked(not editable)
|
||||
lock_action.setVisible(editable)
|
||||
|
||||
attach_all_action = self.toolbar.components.get_action("attach_all").action
|
||||
attach_all_action.setVisible(editable)
|
||||
|
||||
# Show full creation menus only when editable; otherwise keep minimal set
|
||||
if editable:
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
else:
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
|
||||
# Keep Developer mode UI in sync
|
||||
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
|
||||
|
||||
def _on_developer_mode_toggled(self, checked: bool) -> None:
|
||||
"""Handle developer mode checkbox toggle."""
|
||||
self._set_editable(checked)
|
||||
|
||||
################################################################################
|
||||
# Adding widgets
|
||||
################################################################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
closable: bool = True,
|
||||
floatable: bool = True,
|
||||
movable: bool = True,
|
||||
start_floating: bool = False,
|
||||
where: Literal["left", "right", "top", "bottom"] | None = None,
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Create a new widget (or reuse an instance) and add it as a dock.
|
||||
|
||||
Args:
|
||||
widget: Widget instance or a string widget type (factory-created).
|
||||
closable: Whether the dock is closable.
|
||||
floatable: Whether the dock is floatable.
|
||||
movable: Whether the dock is movable.
|
||||
start_floating: Start the dock in a floating state.
|
||||
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
|
||||
If None, uses the instance default passed at construction time.
|
||||
Returns:
|
||||
The widget instance.
|
||||
"""
|
||||
target_area = self._area_from_where(where)
|
||||
|
||||
# 1) Instantiate or look up the widget
|
||||
if isinstance(widget, str):
|
||||
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
|
||||
widget.name_established.connect(
|
||||
lambda: self._create_dock_with_name(
|
||||
widget=widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
area=target_area,
|
||||
)
|
||||
)
|
||||
return widget
|
||||
|
||||
# If a widget instance is passed, dock it immediately
|
||||
self._create_dock_with_name(
|
||||
widget=widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
area=target_area,
|
||||
)
|
||||
return widget
|
||||
|
||||
def _create_dock_with_name(
|
||||
self,
|
||||
widget: BECWidget,
|
||||
closable: bool = True,
|
||||
floatable: bool = False,
|
||||
movable: bool = True,
|
||||
start_floating: bool = False,
|
||||
area: QtAds.DockWidgetArea | None = None,
|
||||
):
|
||||
target_area = area or self._area_from_where(None)
|
||||
self._make_dock(
|
||||
widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
area=target_area,
|
||||
start_floating=start_floating,
|
||||
)
|
||||
self.dock_manager.setFocus()
|
||||
|
||||
################################################################################
|
||||
# Dock Management
|
||||
################################################################################
|
||||
|
||||
def dock_map(self) -> dict[str, CDockWidget]:
|
||||
"""
|
||||
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to their corresponding dock widgets.
|
||||
"""
|
||||
return self.dock_manager.dockWidgetsMap()
|
||||
|
||||
def dock_list(self) -> list[CDockWidget]:
|
||||
"""
|
||||
Return the list of dock widgets.
|
||||
|
||||
Returns:
|
||||
list: A list of all dock widgets in the dock area.
|
||||
"""
|
||||
return self.dock_manager.dockWidgets()
|
||||
|
||||
def widget_map(self) -> dict[str, QWidget]:
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding BECWidget instances.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to BECWidget instances.
|
||||
"""
|
||||
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||
|
||||
def widget_list(self) -> list[QWidget]:
|
||||
"""
|
||||
Return a list of all BECWidget instances in the dock area.
|
||||
|
||||
Returns:
|
||||
list: A list of all BECWidget instances in the dock area.
|
||||
"""
|
||||
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||
|
||||
@SafeSlot()
|
||||
def attach_all(self):
|
||||
"""
|
||||
Return all floating docks to the dock area, preserving tab groups within each floating container.
|
||||
"""
|
||||
for container in self.dock_manager.floatingWidgets():
|
||||
docks = container.dockWidgets()
|
||||
if not docks:
|
||||
continue
|
||||
target = docks[0]
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
|
||||
for d in docks[1:]:
|
||||
self.dock_manager.addDockWidgetTab(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_all(self):
|
||||
"""Delete all docks and widgets."""
|
||||
for dock in list(self.dock_manager.dockWidgets()):
|
||||
self._delete_dock(dock)
|
||||
|
||||
################################################################################
|
||||
# Workspace Management
|
||||
################################################################################
|
||||
@SafeProperty(bool)
|
||||
def lock_workspace(self) -> bool:
|
||||
"""
|
||||
Get or set the lock state of the workspace.
|
||||
|
||||
Returns:
|
||||
bool: True if the workspace is locked, False otherwise.
|
||||
"""
|
||||
return self._locked
|
||||
|
||||
@lock_workspace.setter
|
||||
def lock_workspace(self, value: bool):
|
||||
"""
|
||||
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
|
||||
|
||||
Args:
|
||||
value (bool): True to lock the workspace, False to unlock it.
|
||||
"""
|
||||
self._locked = value
|
||||
self._apply_dock_lock(value)
|
||||
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
|
||||
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
|
||||
for dock in self.dock_list():
|
||||
dock.setting_action.setVisible(not value)
|
||||
|
||||
@SafeSlot(str)
|
||||
def save_profile(self, name: str | None = None):
|
||||
"""
|
||||
Save the current workspace profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||
"""
|
||||
if not name:
|
||||
# Use the new SaveProfileDialog instead of QInputDialog
|
||||
dialog = SaveProfileDialog(self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
name = dialog.get_profile_name()
|
||||
readonly = dialog.is_readonly()
|
||||
|
||||
# Check if profile already exists and is read-only
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
suggested_name = f"{name}_custom"
|
||||
reply = QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
|
||||
f"Would you like to save it with a different name?\n"
|
||||
f"Suggested name: '{suggested_name}'",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.Yes,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
# Show dialog again with suggested name pre-filled
|
||||
dialog = SaveProfileDialog(self, suggested_name)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
name = dialog.get_profile_name()
|
||||
readonly = dialog.is_readonly()
|
||||
|
||||
# Check again if the new name is also read-only (recursive protection)
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
return self.save_profile()
|
||||
else:
|
||||
return
|
||||
else:
|
||||
# If name is provided directly, assume not read-only unless already exists
|
||||
readonly = False
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
|
||||
QMessageBox.Ok,
|
||||
)
|
||||
return
|
||||
|
||||
# Display saving placeholder
|
||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
workspace_combo.blockSignals(True)
|
||||
workspace_combo.insertItem(0, f"{name}-saving")
|
||||
workspace_combo.setCurrentIndex(0)
|
||||
workspace_combo.blockSignals(False)
|
||||
|
||||
# Save the profile
|
||||
settings = open_settings(name)
|
||||
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
|
||||
settings.setValue(
|
||||
SETTINGS_KEYS["state"], b""
|
||||
) # No QMainWindow state; placeholder for backward compat
|
||||
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
|
||||
self.dock_manager.addPerspective(name)
|
||||
self.dock_manager.savePerspectives(settings)
|
||||
self.state_manager.save_state(settings=settings)
|
||||
write_manifest(settings, self.dock_list())
|
||||
|
||||
# Set read-only status if specified
|
||||
if readonly:
|
||||
set_profile_readonly(name, readonly)
|
||||
|
||||
settings.sync()
|
||||
self._refresh_workspace_list()
|
||||
workspace_combo.setCurrentText(name)
|
||||
|
||||
def load_profile(self, name: str | None = None):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||
"""
|
||||
# FIXME this has to be tweaked
|
||||
if not name:
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
settings = open_settings(name)
|
||||
|
||||
for item in read_manifest(settings):
|
||||
obj_name = item["object_name"]
|
||||
widget_class = item["widget_class"]
|
||||
if obj_name not in self.widget_map():
|
||||
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
|
||||
w.setObjectName(obj_name)
|
||||
self._make_dock(
|
||||
w,
|
||||
closable=item["closable"],
|
||||
floatable=item["floatable"],
|
||||
movable=item["movable"],
|
||||
area=QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
)
|
||||
|
||||
geom = settings.value(SETTINGS_KEYS["geom"])
|
||||
if geom:
|
||||
self.restoreGeometry(geom)
|
||||
# No window state for QWidget-based host; keep for backwards compat read
|
||||
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
|
||||
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
|
||||
if dock_state:
|
||||
self.dock_manager.restoreState(dock_state)
|
||||
self.dock_manager.loadPerspectives(settings)
|
||||
self.state_manager.load_state(settings=settings)
|
||||
self._set_editable(self._editable)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_profile(self):
|
||||
"""
|
||||
Delete the currently selected workspace profile file and refresh the combo list.
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
name = combo.currentText()
|
||||
if not name:
|
||||
return
|
||||
|
||||
# Check if profile is read-only
|
||||
if is_profile_readonly(name):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
|
||||
f"Read-only profiles are protected from modification and deletion.",
|
||||
QMessageBox.Ok,
|
||||
)
|
||||
return
|
||||
|
||||
# Confirm deletion for regular profiles
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Are you sure you want to delete the profile '{name}'?\n\n"
|
||||
f"This action cannot be undone.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
file_path = profile_path(name)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
self._refresh_workspace_list()
|
||||
|
||||
def _refresh_workspace_list(self):
|
||||
"""
|
||||
Populate the workspace combo box with all saved profile names (without .ini).
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
if hasattr(combo, "refresh_profiles"):
|
||||
combo.refresh_profiles()
|
||||
else:
|
||||
# Fallback for regular QComboBox
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(list_profiles())
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Mode Switching
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, new_mode: str):
|
||||
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
|
||||
raise ValueError(f"Invalid mode: {new_mode}")
|
||||
self._mode = new_mode
|
||||
self.mode_changed.emit(new_mode)
|
||||
|
||||
# Update toolbar visibility based on mode
|
||||
if new_mode == "user":
|
||||
# User mode: show only essential tools
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
elif new_mode == "developer":
|
||||
# Developer mode: show all tools (use menu bundles)
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
elif new_mode in ["plot", "device", "utils"]:
|
||||
# Specific modes: show flat toolbar for that category
|
||||
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
|
||||
self.toolbar.show_bundles([bundle_name])
|
||||
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
|
||||
else:
|
||||
# Fallback to user mode
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.dark_mode_button.close()
|
||||
self.dark_mode_button.deleteLater()
|
||||
self.toolbar.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
ads = AdvancedDockArea(mode="developer", root_widget=True)
|
||||
window.setCentralWidget(ads)
|
||||
window.show()
|
||||
window.resize(800, 600)
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
|
||||
from PySide6QtAds import CDockWidget
|
||||
from qtpy.QtCore import QSettings
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
|
||||
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
|
||||
|
||||
|
||||
def profiles_dir() -> str:
|
||||
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def profile_path(name: str) -> str:
|
||||
return os.path.join(profiles_dir(), f"{name}.ini")
|
||||
|
||||
|
||||
SETTINGS_KEYS = {
|
||||
"geom": "mainWindow/Geometry",
|
||||
"state": "mainWindow/State",
|
||||
"ads_state": "mainWindow/DockingState",
|
||||
"manifest": "manifest/widgets",
|
||||
"readonly": "profile/readonly",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini"))
|
||||
|
||||
|
||||
def is_profile_readonly(name: str) -> bool:
|
||||
"""Check if a profile is marked as read-only."""
|
||||
settings = open_settings(name)
|
||||
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
|
||||
|
||||
|
||||
def set_profile_readonly(name: str, readonly: bool) -> None:
|
||||
"""Set the read-only status of a profile."""
|
||||
settings = open_settings(name)
|
||||
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
|
||||
settings.sync()
|
||||
|
||||
|
||||
def open_settings(name: str) -> QSettings:
|
||||
return QSettings(profile_path(name), QSettings.IniFormat)
|
||||
|
||||
|
||||
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
|
||||
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
|
||||
for i, dock in enumerate(docks):
|
||||
settings.setArrayIndex(i)
|
||||
w = dock.widget()
|
||||
settings.setValue("object_name", w.objectName())
|
||||
settings.setValue("widget_class", w.__class__.__name__)
|
||||
settings.setValue("closable", getattr(dock, "_default_closable", True))
|
||||
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
|
||||
settings.setValue("movable", getattr(dock, "_default_movable", True))
|
||||
settings.endArray()
|
||||
|
||||
|
||||
def read_manifest(settings: QSettings) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
|
||||
for i in range(count):
|
||||
settings.setArrayIndex(i)
|
||||
items.append(
|
||||
{
|
||||
"object_name": settings.value("object_name"),
|
||||
"widget_class": settings.value("widget_class"),
|
||||
"closable": settings.value("closable", type=bool),
|
||||
"floatable": settings.value("floatable", type=bool),
|
||||
"movable": settings.value("movable", type=bool),
|
||||
}
|
||||
)
|
||||
settings.endArray()
|
||||
return items
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
|
||||
|
||||
from bec_widgets import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
is_profile_readonly,
|
||||
list_profiles,
|
||||
)
|
||||
|
||||
|
||||
class ProfileComboBox(QComboBox):
|
||||
"""Custom combobox that displays icons for read-only profiles."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
def refresh_profiles(self):
|
||||
"""Refresh the profile list with appropriate icons."""
|
||||
|
||||
current_text = self.currentText()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
|
||||
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
|
||||
|
||||
for profile in list_profiles():
|
||||
if is_profile_readonly(profile):
|
||||
self.addItem(lock_icon, f"{profile}")
|
||||
# Set tooltip for read-only profiles
|
||||
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
|
||||
else:
|
||||
self.addItem(profile)
|
||||
|
||||
# Restore selection if possible
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
self.blockSignals(False)
|
||||
|
||||
|
||||
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The workspace toolbar bundle.
|
||||
"""
|
||||
# Lock icon action
|
||||
components.add_safe(
|
||||
"lock",
|
||||
MaterialIconAction(
|
||||
icon_name="lock_open_right",
|
||||
tooltip="Lock Workspace",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
|
||||
# Workspace combo
|
||||
combo = ProfileComboBox(parent=components.toolbar)
|
||||
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
|
||||
|
||||
# Save the current workspace icon
|
||||
components.add_safe(
|
||||
"save_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="save",
|
||||
tooltip="Save Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
# Delete workspace icon
|
||||
components.add_safe(
|
||||
"refresh_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="refresh",
|
||||
tooltip="Refresh Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
# Delete workspace icon
|
||||
components.add_safe(
|
||||
"delete_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="delete",
|
||||
tooltip="Delete Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("workspace", components)
|
||||
bundle.add_action("lock")
|
||||
bundle.add_action("workspace_combo")
|
||||
bundle.add_action("save_workspace")
|
||||
bundle.add_action("refresh_workspace")
|
||||
bundle.add_action("delete_workspace")
|
||||
return bundle
|
||||
|
||||
|
||||
class WorkspaceConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for workspace actions in AdvancedDockArea.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
super().__init__(parent=components.toolbar)
|
||||
self.bundle_name = "workspace"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "lock_workspace"):
|
||||
raise AttributeError("Target widget must implement 'lock_workspace'.")
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
|
||||
self.components.get_action("save_workspace").action.triggered.connect(
|
||||
self.target_widget.save_profile
|
||||
)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
self.components.get_action("refresh_workspace").action.triggered.connect(
|
||||
self._refresh_workspace
|
||||
)
|
||||
self.components.get_action("delete_workspace").action.triggered.connect(
|
||||
self.target_widget.delete_profile
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
|
||||
self.components.get_action("save_workspace").action.triggered.disconnect(
|
||||
self.target_widget.save_profile
|
||||
)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
self.components.get_action("refresh_workspace").action.triggered.disconnect(
|
||||
self._refresh_workspace
|
||||
)
|
||||
self.components.get_action("delete_workspace").action.triggered.disconnect(
|
||||
self.target_widget.delete_profile
|
||||
)
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _lock_workspace(self, value: bool):
|
||||
"""
|
||||
Switches the workspace lock state and change the icon accordingly.
|
||||
"""
|
||||
setattr(self.target_widget, "lock_workspace", value)
|
||||
self.components.get_action("lock").action.setChecked(value)
|
||||
icon = material_icon(
|
||||
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
|
||||
)
|
||||
self.components.get_action("lock").action.setIcon(icon)
|
||||
|
||||
@SafeSlot()
|
||||
def _refresh_workspace(self):
|
||||
"""
|
||||
Refreshes the current workspace.
|
||||
"""
|
||||
combo = self.components.get_action("workspace_combo").widget
|
||||
current_workspace = combo.currentText()
|
||||
self.target_widget.load_profile(current_workspace)
|
||||
@@ -616,10 +616,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
apply_theme("dark")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
||||
dock_1.new(widget="DarkModeButton")
|
||||
|
||||
@@ -12,4 +12,4 @@ class BECWebLinksMixin:
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
SCAN_PROGRESS_HEIGHT = 12 # px
|
||||
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
|
||||
|
||||
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(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
|
||||
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,8 +219,62 @@ 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.
|
||||
@@ -303,7 +357,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
########################################
|
||||
# Theme menu
|
||||
theme_menu = menu_bar.addMenu("Theme")
|
||||
theme_menu = menu_bar.addMenu("View")
|
||||
|
||||
theme_group = QActionGroup(self)
|
||||
light_theme_action = QAction("Light Theme", self, checkable=True)
|
||||
@@ -320,11 +374,12 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
# Set the default theme
|
||||
theme = self.app.theme.theme
|
||||
if theme == "light":
|
||||
light_theme_action.setChecked(True)
|
||||
elif theme == "dark":
|
||||
dark_theme_action.setChecked(True)
|
||||
if hasattr(self.app, "theme") and self.app.theme:
|
||||
theme_name = self.app.theme.theme.lower()
|
||||
if "light" in theme_name:
|
||||
light_theme_action.setChecked(True)
|
||||
elif "dark" in theme_name:
|
||||
dark_theme_action.setChecked(True)
|
||||
|
||||
########################################
|
||||
# Help menu
|
||||
@@ -394,7 +449,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
Args:
|
||||
theme(str): Either "light" or "dark".
|
||||
"""
|
||||
set_theme(theme) # emits theme_updated and applies palette globally
|
||||
apply_theme(theme) # emits theme_updated and applies palette globally
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
@@ -420,6 +475,8 @@ 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
|
||||
|
||||
@@ -38,9 +38,6 @@ class AbortButton(BECWidget, QWidget):
|
||||
else:
|
||||
self.button = QPushButton()
|
||||
self.button.setText("Abort")
|
||||
self.button.setStyleSheet(
|
||||
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
self.button.clicked.connect(self.abort_scan)
|
||||
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
@@ -31,9 +31,7 @@ class StopButton(BECWidget, QWidget):
|
||||
self.button = QPushButton()
|
||||
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.button.setText("Stop")
|
||||
self.button.setStyleSheet(
|
||||
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
self.button.setProperty("variant", "danger")
|
||||
self.button.clicked.connect(self.stop_scan)
|
||||
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
@@ -88,7 +88,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
if not self._check_device_is_valid(device):
|
||||
return
|
||||
|
||||
data = self.dev[device].read(cached=True)
|
||||
data = self.dev[device].read()
|
||||
self._on_device_readback(
|
||||
device,
|
||||
self._device_ui_components(device),
|
||||
|
||||
@@ -12,7 +12,7 @@ from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
|
||||
USER_ACCESS = ["set_positioner", "screenshot"]
|
||||
USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
@@ -259,7 +259,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -13,7 +13,7 @@ from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
@@ -34,15 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
USER_ACCESS = [
|
||||
"set_positioner_hor",
|
||||
"set_positioner_ver",
|
||||
"screenshot",
|
||||
"enable_controls_hor",
|
||||
"enable_controls_hor.setter",
|
||||
"enable_controls_ver",
|
||||
"enable_controls_ver.setter",
|
||||
]
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "attach", "detach", "screenshot"]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
@@ -71,8 +63,6 @@ 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()
|
||||
@@ -291,7 +281,6 @@ 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):
|
||||
@@ -311,7 +300,6 @@ 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":
|
||||
@@ -349,25 +337,6 @@ 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.
|
||||
@@ -448,26 +417,6 @@ 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"""
|
||||
@@ -529,7 +478,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = PositionerBox2D()
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -62,7 +62,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "grid_view"
|
||||
USER_ACCESS = ["set_positioners"]
|
||||
USER_ACCESS = ["set_positioners", "attach", "detach", "screenshot"]
|
||||
|
||||
# Signal emitted to inform listeners about a position update of the first positioner
|
||||
position_update = Signal(float)
|
||||
|
||||
@@ -147,24 +147,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
dev_name = self.currentText()
|
||||
return self.get_device_object(dev_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
"""Extend the paint event to set the border color based on the validity of the input.
|
||||
|
||||
Args:
|
||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
||||
"""
|
||||
# logger.info(f"Received paint event: {event} in {self.__class__}")
|
||||
super().paintEvent(event)
|
||||
|
||||
if self._is_valid_input is False and self.isEnabled() is True:
|
||||
painter = QPainter(self)
|
||||
pen = QPen()
|
||||
pen.setWidth(2)
|
||||
pen.setColor(self._accent_colors.emergency)
|
||||
painter.setPen(pen)
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||||
painter.end()
|
||||
|
||||
@Slot(str)
|
||||
def check_validity(self, input_text: str) -> None:
|
||||
"""
|
||||
@@ -173,10 +155,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
if self.validate_device(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text)
|
||||
self.setStyleSheet("border: 1px solid transparent;")
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.device_reset.emit()
|
||||
self.update()
|
||||
if self.isEnabled():
|
||||
self.setStyleSheet("border: 1px solid red;")
|
||||
|
||||
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
||||
"""
|
||||
@@ -202,10 +186,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -175,13 +175,13 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||
SignalComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -179,10 +179,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -147,13 +147,13 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -20,7 +20,7 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
@@ -45,7 +45,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
Widget to submit new scans to the queue.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["remove", "screenshot"]
|
||||
USER_ACCESS = ["attach", "detach", "screenshot"]
|
||||
PLUGIN = True
|
||||
ICON_NAME = "tune"
|
||||
ARG_BOX_POSITION: int = 2
|
||||
@@ -136,13 +136,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.button_layout = QHBoxLayout(self.scan_control_group)
|
||||
self.button_run_scan = QPushButton("Start", self.scan_control_group)
|
||||
self.button_run_scan.setStyleSheet(
|
||||
f"background-color: {palette.success.name()}; color: white"
|
||||
)
|
||||
self.button_run_scan.setProperty("variant", "success")
|
||||
self.button_stop_scan = StopButton(parent=self.scan_control_group)
|
||||
self.button_stop_scan.setStyleSheet(
|
||||
f"background-color: {palette.emergency.name()}; color: white"
|
||||
)
|
||||
self.button_layout.addWidget(self.button_run_scan)
|
||||
self.button_layout.addWidget(self.button_stop_scan)
|
||||
self.layout.addWidget(self.scan_control_group)
|
||||
@@ -547,12 +542,10 @@ class ScanControl(BECWidget, QWidget):
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
scan_control = ScanControl()
|
||||
|
||||
set_theme("auto")
|
||||
apply_theme("dark")
|
||||
window = scan_control
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
@@ -175,10 +175,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = QWidget()
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@@ -249,10 +249,10 @@ class DictBackedTable(QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
|
||||
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window.show()
|
||||
|
||||
@@ -32,6 +32,9 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"set_vim_mode_enabled",
|
||||
"set_lsp_header",
|
||||
"get_lsp_header",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
|
||||
@@ -85,6 +85,7 @@ 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
|
||||
@@ -96,7 +97,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
class ExampleSchema1(BasicScanMetadata):
|
||||
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
|
||||
@@ -140,7 +141,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
layout.addWidget(selection)
|
||||
layout.addWidget(scan_metadata)
|
||||
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
window = w
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
@@ -21,7 +21,16 @@ class WebsiteWidget(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "travel_explore"
|
||||
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
|
||||
USER_ACCESS = [
|
||||
"set_url",
|
||||
"get_url",
|
||||
"reload",
|
||||
"back",
|
||||
"forward",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
|
||||
|
||||
@@ -407,10 +407,10 @@ class Minesweeper(BECWidget, QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
widget = Minesweeper()
|
||||
widget.show()
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ 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
|
||||
|
||||
@@ -84,7 +83,41 @@ class Heatmap(ImageBase):
|
||||
"""
|
||||
|
||||
USER_ACCESS = [
|
||||
*PlotBase.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",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
@@ -94,6 +127,8 @@ class Heatmap(ImageBase):
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Sequence
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
|
||||
@@ -60,7 +59,41 @@ class Image(ImageBase):
|
||||
RPC = True
|
||||
ICON_NAME = "image"
|
||||
USER_ACCESS = [
|
||||
*PlotBase.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",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
@@ -70,6 +103,8 @@ class Image(ImageBase):
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
@@ -274,7 +309,7 @@ class Image(ImageBase):
|
||||
Set the image source and update the image.
|
||||
|
||||
Args:
|
||||
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(str): The name of the monitor to use for the image.
|
||||
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".
|
||||
@@ -289,13 +324,10 @@ 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, str):
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
elif isinstance(monitor, Sequence):
|
||||
if isinstance(monitor, tuple):
|
||||
self.entry_validator.validate_monitor(monitor[0])
|
||||
else:
|
||||
raise ValueError(f"Invalid monitor type: {type(monitor)}")
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
|
||||
self.set_image_update(monitor=monitor, type=monitor_type)
|
||||
if color_map is not None:
|
||||
@@ -317,7 +349,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, (list, tuple)):
|
||||
if isinstance(config.monitor, tuple):
|
||||
self.device_combo_box.setCurrentText(f"{config.monitor[0]}_{config.monitor[1]}")
|
||||
else:
|
||||
self.device_combo_box.setCurrentText(config.monitor)
|
||||
@@ -422,7 +454,7 @@ class Image(ImageBase):
|
||||
"""
|
||||
|
||||
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
|
||||
if isinstance(monitor, (list, tuple)):
|
||||
if isinstance(monitor, tuple):
|
||||
device = self.dev[monitor[0]]
|
||||
signal = monitor[1]
|
||||
if len(monitor) == 3:
|
||||
@@ -490,7 +522,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, (list, tuple)):
|
||||
if isinstance(monitor, tuple):
|
||||
if self.subscriptions["main"].source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_preview(monitor[0], monitor[1])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -73,16 +73,11 @@ 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``.
|
||||
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.
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
"""
|
||||
|
||||
PLUGIN = False
|
||||
@@ -97,18 +92,11 @@ 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
|
||||
@@ -124,29 +112,22 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self._init_toolbar()
|
||||
if not self.compact:
|
||||
self._init_tree()
|
||||
else:
|
||||
self.tree = None
|
||||
self._init_tree()
|
||||
|
||||
# connect controller
|
||||
self.controller.roiAdded.connect(self._on_roi_added)
|
||||
self.controller.roiRemoved.connect(self._on_roi_removed)
|
||||
if not self.compact:
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
|
||||
# initial load
|
||||
for r in self.controller.rois:
|
||||
self._on_roi_added(r)
|
||||
|
||||
if not self.compact:
|
||||
self.tree.collapseAll()
|
||||
self.tree.collapseAll()
|
||||
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = self.toolbar = ModularToolBar(
|
||||
self, orientation=self.compact_orient if self.compact else "horizontal"
|
||||
)
|
||||
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
|
||||
self._draw_actions: dict[str, MaterialIconAction] = {}
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
|
||||
@@ -176,29 +157,6 @@ 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
|
||||
@@ -369,21 +327,13 @@ 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 hasattr(self, "lock_all_action") and self.lock_all_action.action.isChecked():
|
||||
if self.lock_all_action.action.isChecked():
|
||||
roi.movable = False
|
||||
# parent row with blank action column, name in ROI column
|
||||
parent = QTreeWidgetItem(self.tree, ["", "", ""])
|
||||
@@ -474,10 +424,6 @@ 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)
|
||||
@@ -503,9 +449,8 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
self.controller.remove_roi(roi)
|
||||
|
||||
def cleanup(self):
|
||||
if hasattr(self, "cmap"):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
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:
|
||||
@@ -546,8 +491,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Add the image widget on the left
|
||||
ml.addWidget(image_widget)
|
||||
|
||||
# ROI manager linked to that image with compact mode
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget, compact=True)
|
||||
# ROI manager linked to that image
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
|
||||
mgr.setFixedWidth(350)
|
||||
ml.addWidget(mgr)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
@@ -90,7 +90,47 @@ class MotorMap(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "my_location"
|
||||
USER_ACCESS = [
|
||||
*PlotBase.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",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# motor_map specific
|
||||
"color",
|
||||
"color.setter",
|
||||
@@ -727,7 +767,7 @@ class MotorMap(PlotBase):
|
||||
float: Motor initial position.
|
||||
"""
|
||||
entry = self.entry_validator.validate_signal(name, None)
|
||||
init_position = round(float(self.dev[name].read(cached=True)[entry]["value"]), precision)
|
||||
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
|
||||
return init_position
|
||||
|
||||
def _sync_motor_map_selection_toolbar(self):
|
||||
@@ -790,7 +830,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
|
||||
@@ -56,7 +56,49 @@ class MultiWaveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "ssid_chart"
|
||||
USER_ACCESS = [
|
||||
*PlotBase.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",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
"highlighted_index.setter",
|
||||
|
||||
@@ -63,50 +63,6 @@ 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)
|
||||
@@ -153,7 +109,6 @@ 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
|
||||
@@ -179,7 +134,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._init_ui()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
self._update_theme()
|
||||
self._update_theme(None)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
self.round_plot_widget.apply_theme(theme)
|
||||
@@ -187,6 +142,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
def _init_ui(self):
|
||||
self.layout.addWidget(self.layout_manager)
|
||||
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
|
||||
self.round_plot_widget.setProperty("variant", "plot_background")
|
||||
self.round_plot_widget.setProperty("frameless", True)
|
||||
|
||||
self.layout_manager.add_widget(self.round_plot_widget)
|
||||
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
|
||||
@@ -875,40 +832,6 @@ 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:
|
||||
"""
|
||||
@@ -972,20 +895,15 @@ class PlotBase(BECWidget, QWidget):
|
||||
return
|
||||
self._apply_autorange_only_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 _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
|
||||
|
||||
def _apply_autorange_only_visible_curves(self):
|
||||
"""
|
||||
@@ -994,9 +912,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
Args:
|
||||
curves (list): List of curves to apply autorange to.
|
||||
"""
|
||||
visible_items = self.visible_items
|
||||
|
||||
self.plot_item.autoRange(items=visible_items if visible_items else None)
|
||||
visible_curves = self._fetch_visible_curves()
|
||||
self.plot_item.autoRange(items=visible_curves if visible_curves else None)
|
||||
|
||||
@SafeProperty(int, doc="The font size of the legend font.")
|
||||
def legend_label_size(self) -> int:
|
||||
|
||||
@@ -174,8 +174,6 @@ 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):
|
||||
"""
|
||||
@@ -558,8 +556,8 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
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.
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
@@ -567,17 +565,13 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, 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 {
|
||||
@@ -585,19 +579,8 @@ 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),
|
||||
(cx, cy),
|
||||
(width, height),
|
||||
)
|
||||
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,6 @@ from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
@@ -44,7 +43,49 @@ class ScatterWaveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "scatter_plot"
|
||||
USER_ACCESS = [
|
||||
*PlotBase.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",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
"color_map",
|
||||
@@ -504,8 +545,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
|
||||
@@ -42,15 +42,10 @@ 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", "history"] = Field(
|
||||
source: Literal["device", "dap", "custom"] = 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."
|
||||
)
|
||||
@@ -204,9 +199,8 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
Raises:
|
||||
ValueError: If the source is not custom.
|
||||
"""
|
||||
if self.config.source in ["custom", "history"]:
|
||||
if self.config.source == "custom":
|
||||
self.setData(x, y)
|
||||
self.parent_item.request_dap_update.emit()
|
||||
else:
|
||||
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
|
||||
|
||||
|
||||
@@ -5,35 +5,9 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
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.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
@@ -97,6 +71,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
# A top-level device row.
|
||||
super().__init__(tree)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tree = tree
|
||||
self.parent_item = parent_item
|
||||
self.curve_tree = tree.parent() # The CurveTree widget
|
||||
@@ -118,60 +93,8 @@ 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()
|
||||
@@ -193,8 +116,17 @@ class CurveRow(QTreeWidgetItem):
|
||||
actions_layout.addWidget(self.delete_button)
|
||||
|
||||
# If device row, add "Add DAP" button
|
||||
if self.source in ("device", "history"):
|
||||
self.add_dap_button = QPushButton("DAP")
|
||||
if self.source == "device":
|
||||
self.add_dap_button = QToolButton()
|
||||
analysis_icon = material_icon(
|
||||
"monitoring",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=False,
|
||||
color=self.app.theme.colors["FG"].toTuple(),
|
||||
)
|
||||
self.add_dap_button.setIcon(analysis_icon)
|
||||
self.add_dap_button.setToolTip("Add DAP")
|
||||
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
||||
actions_layout.addWidget(self.add_dap_button)
|
||||
|
||||
@@ -202,7 +134,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 in ("device", "history"):
|
||||
if self.source == "device":
|
||||
# Device row: columns 1..2 are device line edits
|
||||
self.device_edit = DeviceComboBox(parent=self.tree)
|
||||
self.device_edit.insertItem(0, "")
|
||||
@@ -231,6 +163,7 @@ 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")
|
||||
@@ -249,31 +182,31 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.tree.setItemWidget(self, 2, self.dap_combo)
|
||||
|
||||
def _init_style_controls(self):
|
||||
"""Create columns 4..7: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 4
|
||||
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 3
|
||||
self.color_button = ColorButtonNative(color=self.config.color)
|
||||
self.color_button.color_changed.connect(self._on_color_changed)
|
||||
self.tree.setItemWidget(self, 4, self.color_button)
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
|
||||
# Style in col 5
|
||||
# Style in col 4
|
||||
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, 5, self.style_combo)
|
||||
self.tree.setItemWidget(self, 4, self.style_combo)
|
||||
|
||||
# Pen width in col 6
|
||||
# Pen width in col 5
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setRange(1, 20)
|
||||
self.width_spin.setValue(self.config.pen_width)
|
||||
self.tree.setItemWidget(self, 6, self.width_spin)
|
||||
self.tree.setItemWidget(self, 5, self.width_spin)
|
||||
|
||||
# Symbol size in col 7
|
||||
# Symbol size in col 6
|
||||
self.symbol_spin = QSpinBox()
|
||||
self.symbol_spin.setRange(1, 20)
|
||||
self.symbol_spin.setValue(self.config.symbol_size)
|
||||
self.tree.setItemWidget(self, 7, self.symbol_spin)
|
||||
self.tree.setItemWidget(self, 6, self.symbol_spin)
|
||||
|
||||
@SafeSlot(str, verify_sender=True)
|
||||
def _on_color_changed(self, new_color: str):
|
||||
@@ -287,8 +220,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 is 'device' or 'history'."""
|
||||
if self.source not in ("device", "history"):
|
||||
"""Create a new DAP row as a child. Only valid if source='device'."""
|
||||
if self.source != "device":
|
||||
return
|
||||
curve_tree = self.tree.parent()
|
||||
parent_label = self.config.label
|
||||
@@ -366,7 +299,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
Returns:
|
||||
dict: The serialized config based on the GUI state.
|
||||
"""
|
||||
if self.source in ("device", "history"):
|
||||
if self.source == "device":
|
||||
# Gather device name/entry
|
||||
device_name = ""
|
||||
device_entry = ""
|
||||
@@ -387,23 +320,8 @@ class CurveRow(QTreeWidgetItem):
|
||||
)
|
||||
|
||||
self.config.signal = DeviceSignal(name=device_name, entry=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}"
|
||||
self.config.source = "device"
|
||||
self.config.label = f"{device_name}-{device_entry}"
|
||||
else:
|
||||
# DAP logic
|
||||
parent_conf_dict = {}
|
||||
@@ -536,12 +454,10 @@ class CurveTree(BECWidget, QWidget):
|
||||
self.toolbar.show_bundles(["curve_tree"])
|
||||
|
||||
def _init_tree(self):
|
||||
"""Initialize the QTreeWidget with 8 columns and compact widths."""
|
||||
"""Initialize the QTreeWidget with 7 columns and compact widths."""
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setColumnCount(8)
|
||||
self.tree.setHeaderLabels(
|
||||
["Actions", "Name", "Entry", "Scan #", "Color", "Style", "Width", "Symbol"]
|
||||
)
|
||||
self.tree.setColumnCount(7)
|
||||
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
|
||||
|
||||
header = self.tree.header()
|
||||
for idx in range(self.tree.columnCount()):
|
||||
@@ -551,10 +467,10 @@ class CurveTree(BECWidget, QWidget):
|
||||
header.setSectionResizeMode(idx, QHeaderView.Fixed)
|
||||
header.setStretchLastSection(False)
|
||||
self.tree.setColumnWidth(0, 90)
|
||||
self.tree.setColumnWidth(4, 70)
|
||||
self.tree.setColumnWidth(5, 80)
|
||||
self.tree.setColumnWidth(3, 70)
|
||||
self.tree.setColumnWidth(4, 80)
|
||||
self.tree.setColumnWidth(5, 50)
|
||||
self.tree.setColumnWidth(6, 50)
|
||||
self.tree.setColumnWidth(7, 50)
|
||||
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
@@ -678,9 +594,9 @@ class CurveTree(BECWidget, QWidget):
|
||||
self.tree.clear()
|
||||
self.all_items = []
|
||||
|
||||
top_curves = [c for c in self.waveform.curves if c.config.source in ("device", "history")]
|
||||
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
|
||||
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
|
||||
for dev in top_curves:
|
||||
for dev in device_curves:
|
||||
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
|
||||
for dap in dap_curves:
|
||||
if dap.config.parent_label == dev.config.label:
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 (
|
||||
@@ -26,7 +25,7 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
from bec_widgets.utils.colors import Colors, set_theme
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
@@ -36,9 +35,6 @@ 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
|
||||
|
||||
@@ -67,8 +63,52 @@ class Waveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "show_chart"
|
||||
USER_ACCESS = [
|
||||
*PlotBase.USER_ACCESS,
|
||||
# BECWidget Base Class
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# General PlotBase Settings
|
||||
"_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",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
@@ -126,7 +166,6 @@ class Waveform(PlotBase):
|
||||
# Curve data
|
||||
self._sync_curves = []
|
||||
self._async_curves = []
|
||||
self._history_curves = []
|
||||
self._slice_index = None
|
||||
self._dap_curves = []
|
||||
self._mode = None
|
||||
@@ -143,14 +182,12 @@ 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.scan_history_dialog = None
|
||||
self._add_waveform_specific_popup()
|
||||
self._add_fit_parameters_popup()
|
||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
@@ -218,7 +255,7 @@ class Waveform(PlotBase):
|
||||
super().add_side_menus()
|
||||
self._add_dap_summary_side_menu()
|
||||
|
||||
def _add_waveform_specific_popup(self):
|
||||
def _add_fit_parameters_popup(self):
|
||||
"""
|
||||
Add popups to the Waveform widget.
|
||||
"""
|
||||
@@ -228,24 +265,11 @@ 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):
|
||||
@@ -393,47 +417,6 @@ 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
|
||||
|
||||
@@ -523,11 +506,7 @@ 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)
|
||||
@@ -555,8 +534,6 @@ 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)
|
||||
@@ -697,8 +674,6 @@ 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:
|
||||
"""
|
||||
@@ -718,13 +693,9 @@ 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. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
scan_number(int): Optional scan index. When provided, the curve is treated as a **history** curve and
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Curve: The curve object.
|
||||
@@ -794,8 +765,6 @@ class Waveform(PlotBase):
|
||||
label=label,
|
||||
color=color,
|
||||
source=source,
|
||||
scan_id=scan_id,
|
||||
scan_number=scan_number,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -803,13 +772,10 @@ 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 curve.config.source in ("device", "history", "custom"):
|
||||
if dap is not None and source == "device":
|
||||
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
|
||||
|
||||
return curve
|
||||
@@ -826,12 +792,11 @@ class Waveform(PlotBase):
|
||||
**kwargs,
|
||||
) -> Curve:
|
||||
"""
|
||||
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.
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
device_label(str): The label of the device 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.
|
||||
@@ -841,25 +806,20 @@ class Waveform(PlotBase):
|
||||
Curve: The new DAP curve.
|
||||
"""
|
||||
|
||||
# 1) Find the existing curve by label
|
||||
# 1) Find the existing device 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 not in ("device", "history", "custom"):
|
||||
if device_curve.config.source != "device":
|
||||
raise ValueError(
|
||||
f"Curve '{device_label}' is not compatible with DAP. "
|
||||
f"Only device, history, or custom curves support fitting."
|
||||
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
|
||||
)
|
||||
|
||||
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"
|
||||
dev_name = device_curve.config.signal.name
|
||||
dev_entry = device_curve.config.signal.entry
|
||||
|
||||
# 2) Build a label for the new DAP curve
|
||||
dap_label = f"{device_label}-{dap_name}"
|
||||
dap_label = f"{dev_name}-{dev_entry}-{dap_name}"
|
||||
|
||||
# 3) Possibly raise if the DAP curve already exists
|
||||
if self._check_curve_id(dap_label):
|
||||
@@ -912,23 +872,7 @@ 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(
|
||||
@@ -950,7 +894,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, scan_item=scan_item)
|
||||
curve = self._add_curve_object(name=label, config=config)
|
||||
|
||||
# If custom => set initial data
|
||||
if config.source == "custom" and x_data is not None and y_data is not None:
|
||||
@@ -967,8 +911,6 @@ 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
|
||||
@@ -976,182 +918,22 @@ class Waveform(PlotBase):
|
||||
|
||||
return curve
|
||||
|
||||
def _add_curve_object(
|
||||
self, name: str, config: CurveConfig, scan_item: ScanDataContainer | None = None
|
||||
) -> Curve | None:
|
||||
def _add_curve_object(self, name: str, config: CurveConfig) -> Curve:
|
||||
"""
|
||||
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.
|
||||
@@ -1179,42 +961,7 @@ class Waveform(PlotBase):
|
||||
Clear all data from the plot widget, but keep the curve references.
|
||||
"""
|
||||
for c in self.curves:
|
||||
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))
|
||||
c.clear_data()
|
||||
|
||||
def clear_all(self):
|
||||
"""
|
||||
@@ -1371,13 +1118,12 @@ class Waveform(PlotBase):
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
self.auto_range_x = True
|
||||
self.auto_range_y = True
|
||||
self.auto_range(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
|
||||
@@ -1455,7 +1201,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 np.isscalar(x_data):
|
||||
if len(x_data) == 1:
|
||||
self.clear_data()
|
||||
return
|
||||
if device_data is not None and x_data is not None:
|
||||
@@ -1628,9 +1374,6 @@ 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:
|
||||
@@ -1866,7 +1609,6 @@ 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":
|
||||
@@ -1881,13 +1623,11 @@ 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":
|
||||
@@ -1895,7 +1635,6 @@ 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:
|
||||
@@ -1918,7 +1657,6 @@ 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
|
||||
|
||||
@@ -2021,83 +1759,49 @@ class Waveform(PlotBase):
|
||||
logger.info(f"Scan {self.scan_id} => mode={self._mode}")
|
||||
return mode
|
||||
|
||||
def get_history_scan_item(
|
||||
self, scan_index: int = None, scan_id: str = None
|
||||
) -> ScanDataContainer | None:
|
||||
"""
|
||||
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 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:
|
||||
scan_index = None # Prefer scan_id when both are given
|
||||
|
||||
if scan_index is None and scan_id is None:
|
||||
logger.warning("Neither scan_id or scan_number was provided, fetching the latest scan")
|
||||
scan_index = -1
|
||||
|
||||
if scan_index is None:
|
||||
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 None
|
||||
return scan_item
|
||||
|
||||
if len(self.client.history) == 0:
|
||||
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.
|
||||
Provide only one of scan_id or scan_index.
|
||||
|
||||
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.
|
||||
scan_index(int, optional): Index 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 scan_index is not None and scan_id is not None:
|
||||
raise ValueError("Only one of scan_id or scan_index can be provided.")
|
||||
|
||||
if self.scan_item is None:
|
||||
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")
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
if len(self.client.history) == 0:
|
||||
logger.info("No scans executed so far. Skipping scan history update.")
|
||||
return
|
||||
|
||||
self.scan_item = self.client.history[scan_index]
|
||||
metadata = self.scan_item.metadata
|
||||
self.scan_id = metadata["bec"]["scan_id"]
|
||||
|
||||
self._emit_signal_update()
|
||||
|
||||
@@ -2328,9 +2032,6 @@ 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()
|
||||
|
||||
|
||||
@@ -2338,7 +2039,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Waveform Demo")
|
||||
self.resize(1200, 600)
|
||||
self.resize(800, 600)
|
||||
self.main_widget = QWidget(self)
|
||||
self.layout = QHBoxLayout(self.main_widget)
|
||||
self.setCentralWidget(self.main_widget)
|
||||
@@ -2350,38 +2051,15 @@ 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
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = DemoApp()
|
||||
widget.show()
|
||||
widget.resize(1400, 600)
|
||||
|
||||
@@ -12,8 +12,8 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class ProgressbarConnections(BaseModel):
|
||||
slot: Literal["on_scan_progress", "on_device_readback", None] = None
|
||||
endpoint: EndpointInfo | str | None = None
|
||||
slot: Literal["on_scan_progress", "on_device_readback"] = None
|
||||
endpoint: EndpointInfo | str = None
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("endpoint")
|
||||
@@ -222,10 +222,9 @@ class Ring(BECConnector, QObject):
|
||||
device(str): Device name for the device readback mode, only used when mode is "device"
|
||||
"""
|
||||
if mode == "manual":
|
||||
if self.config.connections.slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
getattr(self, self.config.connections.slot), self.config.connections.endpoint
|
||||
)
|
||||
self.config.connections.slot = None
|
||||
self.config.connections.endpoint = None
|
||||
elif mode == "scan":
|
||||
|
||||
@@ -22,9 +22,13 @@ class RingProgressBarConfig(ConnectionConfig):
|
||||
color_map: Optional[str] = Field(
|
||||
"plasma", description="Color scheme for the progress bars.", validate_default=True
|
||||
)
|
||||
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.")
|
||||
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.")
|
||||
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."
|
||||
@@ -92,6 +96,9 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
"set_diameter",
|
||||
"reset_diameter",
|
||||
"enable_auto_updates",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -238,7 +245,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 = None):
|
||||
def set_precision(self, precision: int, bar_index: int = None):
|
||||
"""
|
||||
Set the precision for the progress bars. If bar_index is not provide, the precision will be set for all progress bars.
|
||||
|
||||
@@ -267,9 +274,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, float)):
|
||||
if isinstance(min_values, int) or isinstance(min_values, float):
|
||||
min_values = [min_values]
|
||||
if isinstance(max_values, (int, float)):
|
||||
if isinstance(max_values, int) or isinstance(max_values, float):
|
||||
max_values = [max_values]
|
||||
min_values = self._adjust_list_to_bars(min_values)
|
||||
max_values = self._adjust_list_to_bars(max_values)
|
||||
@@ -437,10 +444,14 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
Returns:
|
||||
Ring: Ring object.
|
||||
"""
|
||||
found_ring = None
|
||||
for ring in self._rings:
|
||||
if ring.config.index == index:
|
||||
return ring
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
found_ring = ring
|
||||
break
|
||||
if found_ring is None:
|
||||
raise ValueError(f"Ring with index {index} not found.")
|
||||
return found_ring
|
||||
|
||||
def enable_auto_updates(self, enable: bool = True):
|
||||
"""
|
||||
@@ -477,30 +488,29 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
primary_queue = msg.get("queue").get("primary")
|
||||
info = primary_queue.get("info", None)
|
||||
|
||||
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
|
||||
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.")
|
||||
|
||||
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.")
|
||||
# elif instruction_type == "device_progress":
|
||||
# print("hook device_progress")
|
||||
|
||||
def _hook_scan_progress(self, ring_index: int | None = None):
|
||||
def _hook_scan_progress(self, ring_index: int = None):
|
||||
"""
|
||||
Hook the scan progress to the progress bars.
|
||||
|
||||
@@ -514,7 +524,8 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
|
||||
if ring.config.connections.slot == "on_scan_progress":
|
||||
return
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
else:
|
||||
ring.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
|
||||
|
||||
def _hook_readback(self, bar_index: int, device: str, min: float | int, max: float | int):
|
||||
"""
|
||||
@@ -568,8 +579,6 @@ 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())
|
||||
@@ -622,8 +631,9 @@ 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 = max(total_width * 2, 50)
|
||||
|
||||
diameter = total_width * 2
|
||||
if diameter < 50:
|
||||
diameter = 50
|
||||
return QSize(diameter, diameter)
|
||||
|
||||
def sizeHint(self):
|
||||
|
||||
@@ -242,8 +242,15 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
abort_button.button.setIcon(
|
||||
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
|
||||
)
|
||||
abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ")
|
||||
abort_button.button.setFlat(True)
|
||||
abort_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
return abort_button
|
||||
|
||||
def delete_selected_row(self):
|
||||
|
||||
@@ -76,7 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
|
||||
|
||||
PLUGIN = True
|
||||
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
||||
USER_ACCESS = ["get_server_state", "remove"]
|
||||
USER_ACCESS = ["get_server_state", "remove", "attach", "detach", "screenshot"]
|
||||
|
||||
service_update = Signal(BECServiceInfoContainer)
|
||||
bec_core_state = Signal(str)
|
||||
@@ -315,10 +315,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
main_window = BECStatusBox()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -240,10 +240,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
widget = DeviceBrowser()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -262,12 +262,12 @@ def main(): # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
dialog = None
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
widget = QWidget()
|
||||
widget.setLayout(QVBoxLayout())
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ class SignalDisplay(BECWidget, QWidget):
|
||||
@SafeSlot()
|
||||
def _refresh(self):
|
||||
if (dev := self.dev.get(self.device)) is not None:
|
||||
dev.read(cached=True)
|
||||
dev.read_configuration(cached=True)
|
||||
dev.read()
|
||||
dev.read_configuration()
|
||||
|
||||
def _add_refresh_button(self):
|
||||
button_holder = QWidget()
|
||||
@@ -110,10 +110,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
widget = SignalDisplay(device="samx")
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -35,7 +35,7 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import get_theme_palette, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
|
||||
@@ -544,7 +544,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = LogPanel()
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from qtpy.QtCore import QMargins, Qt, Signal
|
||||
from qtpy.QtGui import QIntValidator
|
||||
from qtpy.QtPdf import QPdfDocument
|
||||
from qtpy.QtPdfWidgets import QPdfView
|
||||
from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
|
||||
class PdfViewerWidget(BECWidget, QWidget):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
# Emitted when a PDF document is successfully loaded, providing the file path.
|
||||
document_ready = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "picture_as_pdf"
|
||||
USER_ACCESS = [
|
||||
"load_pdf",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"fit_to_width",
|
||||
"fit_to_page",
|
||||
"reset_zoom",
|
||||
"previous_page",
|
||||
"next_page",
|
||||
"toggle_continuous_scroll",
|
||||
"page_spacing",
|
||||
"page_spacing.setter",
|
||||
"side_margins",
|
||||
"side_margins.setter",
|
||||
"go_to_first_page",
|
||||
"go_to_last_page",
|
||||
"jump_to_page",
|
||||
"current_page",
|
||||
"current_file_path",
|
||||
"current_file_path.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, config=None, client=None, gui_id=None, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Set up the layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create the PDF document and view first
|
||||
self._pdf_document = QPdfDocument(self)
|
||||
self.pdf_view = QPdfView()
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
|
||||
# Create toolbar after PDF components are initialized
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self._setup_toolbar()
|
||||
|
||||
# Add widgets to layout
|
||||
layout.addWidget(self.toolbar)
|
||||
layout.addWidget(self.pdf_view)
|
||||
|
||||
# Current file path and spacing settings
|
||||
self._current_file_path = None
|
||||
self._page_spacing = 5 # Default spacing between pages in continuous mode
|
||||
self._side_margins = 10 # Default side margins (horizontal spacing)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
"""Set up the toolbar with PDF control buttons."""
|
||||
# Create separate bundles for different control groups
|
||||
file_bundle = self.toolbar.new_bundle("file_controls")
|
||||
zoom_bundle = self.toolbar.new_bundle("zoom_controls")
|
||||
view_bundle = self.toolbar.new_bundle("view_controls")
|
||||
nav_bundle = self.toolbar.new_bundle("navigation_controls")
|
||||
|
||||
# File operations
|
||||
open_action = MaterialIconAction(
|
||||
icon_name="folder_open", tooltip="Open PDF File", parent=self
|
||||
)
|
||||
open_action.action.triggered.connect(self.open_file_dialog)
|
||||
self.toolbar.components.add("open_file", open_action)
|
||||
file_bundle.add_action("open_file")
|
||||
|
||||
# Zoom controls
|
||||
zoom_in_action = MaterialIconAction(icon_name="zoom_in", tooltip="Zoom In", parent=self)
|
||||
zoom_in_action.action.triggered.connect(self.zoom_in)
|
||||
self.toolbar.components.add("zoom_in", zoom_in_action)
|
||||
zoom_bundle.add_action("zoom_in")
|
||||
|
||||
zoom_out_action = MaterialIconAction(icon_name="zoom_out", tooltip="Zoom Out", parent=self)
|
||||
zoom_out_action.action.triggered.connect(self.zoom_out)
|
||||
self.toolbar.components.add("zoom_out", zoom_out_action)
|
||||
zoom_bundle.add_action("zoom_out")
|
||||
|
||||
fit_width_action = MaterialIconAction(
|
||||
icon_name="fit_screen", tooltip="Fit to Width", parent=self
|
||||
)
|
||||
fit_width_action.action.triggered.connect(self.fit_to_width)
|
||||
self.toolbar.components.add("fit_width", fit_width_action)
|
||||
zoom_bundle.add_action("fit_width")
|
||||
|
||||
fit_page_action = MaterialIconAction(
|
||||
icon_name="fullscreen", tooltip="Fit to Page", parent=self
|
||||
)
|
||||
fit_page_action.action.triggered.connect(self.fit_to_page)
|
||||
self.toolbar.components.add("fit_page", fit_page_action)
|
||||
zoom_bundle.add_action("fit_page")
|
||||
|
||||
reset_zoom_action = MaterialIconAction(
|
||||
icon_name="center_focus_strong", tooltip="Reset Zoom to 100%", parent=self
|
||||
)
|
||||
reset_zoom_action.action.triggered.connect(self.reset_zoom)
|
||||
self.toolbar.components.add("reset_zoom", reset_zoom_action)
|
||||
zoom_bundle.add_action("reset_zoom")
|
||||
|
||||
# View controls
|
||||
continuous_scroll_action = MaterialIconAction(
|
||||
icon_name="view_agenda", tooltip="Toggle Continuous Scroll", checkable=True, parent=self
|
||||
)
|
||||
continuous_scroll_action.action.toggled.connect(self.toggle_continuous_scroll)
|
||||
self.toolbar.components.add("continuous_scroll", continuous_scroll_action)
|
||||
view_bundle.add_action("continuous_scroll")
|
||||
|
||||
# Navigation controls
|
||||
prev_page_action = MaterialIconAction(
|
||||
icon_name="navigate_before", tooltip="Previous Page", parent=self
|
||||
)
|
||||
prev_page_action.action.triggered.connect(self.previous_page)
|
||||
self.toolbar.components.add("prev_page", prev_page_action)
|
||||
nav_bundle.add_action("prev_page")
|
||||
|
||||
next_page_action = MaterialIconAction(
|
||||
icon_name="navigate_next", tooltip="Next Page", parent=self
|
||||
)
|
||||
next_page_action.action.triggered.connect(self.next_page)
|
||||
self.toolbar.components.add("next_page", next_page_action)
|
||||
nav_bundle.add_action("next_page")
|
||||
|
||||
# Page jump widget (in navigation bundle)
|
||||
self._setup_page_jump_widget(nav_bundle)
|
||||
|
||||
# Show all bundles
|
||||
self.toolbar.show_bundles(
|
||||
["file_controls", "zoom_controls", "view_controls", "navigation_controls"]
|
||||
)
|
||||
|
||||
# Initialize navigation button tooltips for single page mode (default)
|
||||
self._update_navigation_buttons_for_mode(continuous=False)
|
||||
|
||||
# Initialize navigation button states
|
||||
self._update_navigation_button_states()
|
||||
|
||||
def _setup_page_jump_widget(self, nav_bundle):
|
||||
"""Set up the page jump widget (label + line edit)."""
|
||||
# Create a container widget for the page jump controls
|
||||
page_jump_container = QWidget()
|
||||
page_jump_layout = QHBoxLayout(page_jump_container)
|
||||
page_jump_layout.setContentsMargins(5, 0, 5, 0)
|
||||
page_jump_layout.setSpacing(3)
|
||||
|
||||
# Page input field
|
||||
self.page_input = QLineEdit()
|
||||
self.page_input.setValidator(QIntValidator(1, 100000)) # restrict to 1–100000
|
||||
self.page_input.setFixedWidth(50)
|
||||
self.page_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.page_input.setPlaceholderText("1")
|
||||
self.page_input.setToolTip("Enter page number and press Enter")
|
||||
self.page_input.returnPressed.connect(self._line_edit_jump_to_page)
|
||||
|
||||
# Total pages label
|
||||
self.total_pages_label = QLabel("/ 1")
|
||||
self.total_pages_label.setStyleSheet("color: #666; font-size: 12px;")
|
||||
|
||||
# Add widgets to layout
|
||||
page_jump_layout.addWidget(self.page_input)
|
||||
page_jump_layout.addWidget(self.total_pages_label)
|
||||
|
||||
# Create a WidgetAction for the page jump controls
|
||||
# No manual separator needed - bundles are automatically separated
|
||||
page_jump_action = WidgetAction(
|
||||
label="Page:", widget=page_jump_container, adjust_size=False, parent=self
|
||||
)
|
||||
self.toolbar.components.add("page_jump", page_jump_action)
|
||||
nav_bundle.add_action("page_jump")
|
||||
|
||||
def _line_edit_jump_to_page(self):
|
||||
"""Jump to the page entered in the line edit."""
|
||||
page_text = self.page_input.text().strip()
|
||||
if not page_text:
|
||||
return
|
||||
# We validated input to be integer, so safe to convert directly
|
||||
self.jump_to_page(int(page_text))
|
||||
|
||||
def _update_navigation_button_states(self):
|
||||
"""Update the enabled/disabled state of navigation buttons."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
# No document loaded - disable all navigation
|
||||
self._set_navigation_enabled(False, False)
|
||||
self._update_page_display(1, 1)
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
total_pages = self._pdf_document.pageCount()
|
||||
|
||||
# Update button states
|
||||
prev_enabled = current_page > 0
|
||||
next_enabled = current_page < (total_pages - 1)
|
||||
self._set_navigation_enabled(prev_enabled, next_enabled)
|
||||
|
||||
# Update page display
|
||||
self._update_page_display(current_page + 1, total_pages)
|
||||
|
||||
def _set_navigation_enabled(self, prev_enabled: bool, next_enabled: bool):
|
||||
"""Set the enabled state of navigation buttons."""
|
||||
prev_action = self.toolbar.components.get_action("prev_page")
|
||||
if prev_action and hasattr(prev_action, "action") and prev_action.action:
|
||||
prev_action.action.setEnabled(prev_enabled)
|
||||
|
||||
next_action = self.toolbar.components.get_action("next_page")
|
||||
if next_action and hasattr(next_action, "action") and next_action.action:
|
||||
next_action.action.setEnabled(next_enabled)
|
||||
|
||||
def _update_page_display(self, current_page: int, total_pages: int):
|
||||
"""Update the page display in the toolbar."""
|
||||
if hasattr(self, "page_input"):
|
||||
self.page_input.setText(str(current_page))
|
||||
self.page_input.setPlaceholderText(str(current_page))
|
||||
|
||||
if hasattr(self, "total_pages_label"):
|
||||
self.total_pages_label.setText(f"/ {total_pages}")
|
||||
|
||||
@SafeProperty(str)
|
||||
def current_file_path(self):
|
||||
"""Get the current PDF file path."""
|
||||
return self._current_file_path
|
||||
|
||||
@current_file_path.setter
|
||||
def current_file_path(self, value: str):
|
||||
"""
|
||||
Set the current PDF file path and load the document.
|
||||
|
||||
Args:
|
||||
value (str): Path to the PDF file to load.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("current_file_path must be a string")
|
||||
self.load_pdf(value)
|
||||
|
||||
@SafeProperty(int)
|
||||
def page_spacing(self):
|
||||
"""Get the spacing between pages in continuous scroll mode."""
|
||||
return self._page_spacing
|
||||
|
||||
@property
|
||||
def current_page(self):
|
||||
"""Get the current page number (1-based index)."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return 0
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
return navigator.currentPage() + 1
|
||||
|
||||
@page_spacing.setter
|
||||
def page_spacing(self, value: int):
|
||||
"""
|
||||
Set the spacing between pages in continuous scroll mode.
|
||||
|
||||
Args:
|
||||
value (int): Spacing in pixels (non-negative integer).
|
||||
"""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("page_spacing must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError("page_spacing must be non-negative")
|
||||
|
||||
self._page_spacing = value
|
||||
|
||||
# If currently in continuous scroll mode, update the spacing immediately
|
||||
if self.pdf_view.pageMode() == QPdfView.PageMode.MultiPage:
|
||||
self.pdf_view.setPageSpacing(self._page_spacing)
|
||||
|
||||
@SafeProperty(int)
|
||||
def side_margins(self):
|
||||
"""Get the horizontal margins (side spacing) around the PDF content."""
|
||||
return self._side_margins
|
||||
|
||||
@side_margins.setter
|
||||
def side_margins(self, value: int):
|
||||
"""Set the horizontal margins (side spacing) around the PDF content."""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("side_margins must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError("side_margins must be non-negative")
|
||||
|
||||
self._side_margins = value
|
||||
|
||||
# Update the document margins immediately
|
||||
# setDocumentMargins takes a QMargins(left, top, right, bottom)
|
||||
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
|
||||
self.pdf_view.setDocumentMargins(margins)
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open a file dialog to select a PDF file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open PDF File", "", "PDF Files (*.pdf);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.load_pdf(file_path)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
Load a PDF file into the viewer.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the PDF file to load.
|
||||
"""
|
||||
# Validate file exists
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
self._current_file_path = file_path
|
||||
|
||||
# Disconnect any existing signal connections
|
||||
try:
|
||||
self._pdf_document.statusChanged.disconnect(self._on_document_status_changed)
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Connect to statusChanged signal to handle when document is ready
|
||||
self._pdf_document.statusChanged.connect(self._on_document_status_changed)
|
||||
|
||||
# Load the document
|
||||
self._pdf_document.load(file_path)
|
||||
|
||||
# If already ready (synchronous loading), set document immediately
|
||||
if self._pdf_document.status() == QPdfDocument.Status.Ready:
|
||||
self._on_document_ready()
|
||||
|
||||
@SafeSlot(QPdfDocument.Status)
|
||||
def _on_document_status_changed(self, status: QPdfDocument.Status):
|
||||
"""Handle document status changes."""
|
||||
status = self._pdf_document.status()
|
||||
|
||||
if status == QPdfDocument.Status.Ready:
|
||||
self._on_document_ready()
|
||||
elif status == QPdfDocument.Status.Error:
|
||||
raise RuntimeError(f"Failed to load PDF document: {self._current_file_path}")
|
||||
|
||||
def _on_document_ready(self):
|
||||
"""Handle when document is ready to be displayed."""
|
||||
self.pdf_view.setDocument(self._pdf_document)
|
||||
|
||||
# Set initial margins
|
||||
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
|
||||
self.pdf_view.setDocumentMargins(margins)
|
||||
|
||||
# Connect to page changes to update navigation button states
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.currentPageChanged.connect(self._on_page_changed)
|
||||
|
||||
# Make sure we start at the first page
|
||||
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
# Update initial navigation state
|
||||
self._update_navigation_button_states()
|
||||
self.document_ready.emit(self._current_file_path)
|
||||
|
||||
def _on_page_changed(self, _page):
|
||||
"""Handle page change events to update navigation states."""
|
||||
self._update_navigation_button_states()
|
||||
|
||||
# Toolbar action methods
|
||||
@SafeSlot()
|
||||
def zoom_in(self):
|
||||
"""Zoom in the PDF view."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
current_factor = self.pdf_view.zoomFactor()
|
||||
new_factor = current_factor * 1.25
|
||||
self.pdf_view.setZoomFactor(new_factor)
|
||||
|
||||
@SafeSlot()
|
||||
def zoom_out(self):
|
||||
"""Zoom out the PDF view."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
current_factor = self.pdf_view.zoomFactor()
|
||||
new_factor = max(current_factor / 1.25, 0.1)
|
||||
self.pdf_view.setZoomFactor(new_factor)
|
||||
|
||||
@SafeSlot()
|
||||
def fit_to_width(self):
|
||||
"""Fit PDF to width."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
|
||||
@SafeSlot()
|
||||
def fit_to_page(self):
|
||||
"""Fit PDF to page."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitInView)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_zoom(self):
|
||||
"""Reset zoom to 100% (1.0 factor)."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
self.pdf_view.setZoomFactor(1.0)
|
||||
|
||||
@SafeSlot()
|
||||
def previous_page(self):
|
||||
"""Go to previous page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
if current_page == 0:
|
||||
self._update_navigation_button_states()
|
||||
return
|
||||
|
||||
try:
|
||||
target_page = current_page - 1
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: Use scroll to approximate position
|
||||
page_height = self.pdf_view.viewport().height()
|
||||
self.pdf_view.verticalScrollBar().setValue(
|
||||
self.pdf_view.verticalScrollBar().value() - page_height
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update navigation button states (in case signal doesn't fire)
|
||||
self._update_navigation_button_states()
|
||||
|
||||
@SafeSlot()
|
||||
def next_page(self):
|
||||
"""Go to next page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
max_page = self._pdf_document.pageCount() - 1
|
||||
if current_page < max_page:
|
||||
try:
|
||||
target_page = current_page + 1
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: Use scroll to approximate position
|
||||
page_height = self.pdf_view.viewport().height()
|
||||
self.pdf_view.verticalScrollBar().setValue(
|
||||
self.pdf_view.verticalScrollBar().value() + page_height
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update navigation button states (in case signal doesn't fire)
|
||||
self._update_navigation_button_states()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def toggle_continuous_scroll(self, checked: bool):
|
||||
"""
|
||||
Toggle between single page and continuous scroll mode.
|
||||
|
||||
Args:
|
||||
checked (bool): True to enable continuous scroll, False for single page mode.
|
||||
"""
|
||||
if checked:
|
||||
self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self.pdf_view.setPageSpacing(self._page_spacing)
|
||||
self._update_navigation_buttons_for_mode(continuous=True)
|
||||
tooltip = "Switch to Single Page Mode"
|
||||
else:
|
||||
self.pdf_view.setPageMode(QPdfView.PageMode.SinglePage)
|
||||
self._update_navigation_buttons_for_mode(continuous=False)
|
||||
tooltip = "Switch to Continuous Scroll Mode"
|
||||
|
||||
# Update navigation button states after mode change
|
||||
self._update_navigation_button_states()
|
||||
|
||||
# Update toggle button tooltip to reflect current state
|
||||
action = self.toolbar.components.get_action("continuous_scroll")
|
||||
if action and hasattr(action, "action") and action.action:
|
||||
action.action.setToolTip(tooltip)
|
||||
|
||||
def _update_navigation_buttons_for_mode(self, continuous: bool):
|
||||
"""Update navigation button tooltips based on current mode."""
|
||||
prev_action = self.toolbar.components.get_action("prev_page")
|
||||
next_action = self.toolbar.components.get_action("next_page")
|
||||
|
||||
if continuous:
|
||||
prev_actions_tooltip = "Previous Page (use scroll in continuous mode)"
|
||||
next_actions_tooltip = "Next Page (use scroll in continuous mode)"
|
||||
else:
|
||||
prev_actions_tooltip = "Previous Page"
|
||||
next_actions_tooltip = "Next Page"
|
||||
|
||||
if prev_action and hasattr(prev_action, "action") and prev_action.action:
|
||||
prev_action.action.setToolTip(prev_actions_tooltip)
|
||||
if next_action and hasattr(next_action, "action") and next_action.action:
|
||||
next_action.action.setToolTip(next_actions_tooltip)
|
||||
|
||||
@SafeSlot()
|
||||
def go_to_first_page(self):
|
||||
"""Go to the first page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
@SafeSlot()
|
||||
def go_to_last_page(self):
|
||||
"""Go to the last page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
last_page = self._pdf_document.pageCount() - 1
|
||||
navigator.update(last_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
@SafeSlot(int)
|
||||
def jump_to_page(self, page_number: int):
|
||||
"""Jump to a specific page number (1-based index)."""
|
||||
if not isinstance(page_number, int):
|
||||
raise ValueError("page_number must be an integer")
|
||||
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
raise RuntimeError("No PDF document loaded")
|
||||
|
||||
max_page = self._pdf_document.pageCount()
|
||||
page_number = max(min(page_number, max_page), 1)
|
||||
|
||||
target_page = page_number - 1 # Convert to 0-based index
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
def cleanup(self):
|
||||
"""Handle widget close event to prevent segfaults."""
|
||||
if hasattr(self, "_pdf_document") and self._pdf_document:
|
||||
self._pdf_document.statusChanged.disconnect()
|
||||
empty_doc = QPdfDocument(self)
|
||||
self.pdf_view.setDocument(empty_doc)
|
||||
|
||||
if hasattr(self, "toolbar"):
|
||||
self.toolbar.cleanup()
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
# apply_theme("dark")
|
||||
viewer = PdfViewerWidget()
|
||||
# viewer.load_pdf("/Path/To/Your/TestDocument.pdf")
|
||||
viewer.next_page()
|
||||
# viewer.page_spacing = 0
|
||||
# viewer.side_margins = 0
|
||||
viewer.resize(1000, 700)
|
||||
viewer.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['pdf_viewer.py']}
|
||||
@@ -1,57 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from 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()
|
||||
@@ -1,17 +0,0 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer_widget_plugin import (
|
||||
PdfViewerWidgetPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(PdfViewerWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -273,9 +273,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
if not isinstance(self._device_obj, Device | Signal):
|
||||
self._value, self._units = "__", ""
|
||||
return
|
||||
reading = (self._device_obj.read(cached=True) or {}) | (
|
||||
self._device_obj.read_configuration(cached=True) or {}
|
||||
)
|
||||
reading = (self._device_obj.read() or {}) | (self._device_obj.read_configuration() or {})
|
||||
value = reading.get(self._signal_key, {}).get("value")
|
||||
if value is None:
|
||||
self._value, self._units = "__", ""
|
||||
|
||||
@@ -49,7 +49,7 @@ class SpinnerWidget(QWidget):
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
size = min(self.width(), self.height())
|
||||
rect = QRect(0, 0, size, size)
|
||||
|
||||
@@ -63,14 +63,14 @@ class SpinnerWidget(QWidget):
|
||||
rect.adjust(line_width, line_width, -line_width, -line_width)
|
||||
|
||||
# Background arc
|
||||
painter.setPen(QPen(background_color, line_width, Qt.SolidLine))
|
||||
painter.setPen(QPen(background_color, line_width, Qt.PenStyle.SolidLine))
|
||||
adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height())
|
||||
painter.drawArc(adjusted_rect, 0, 360 * 16)
|
||||
|
||||
if self._started:
|
||||
# Foreground arc
|
||||
pen = QPen(color, line_width, Qt.SolidLine)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen = QPen(color, line_width, Qt.PenStyle.SolidLine)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
proportion = 1 / 4
|
||||
angle_span = int(proportion * 360 * 16)
|
||||
|
||||
@@ -5,7 +5,7 @@ from qtpy.QtCore import Property, Qt, Slot
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
|
||||
class DarkModeButton(BECWidget, QWidget):
|
||||
@@ -85,7 +85,7 @@ class DarkModeButton(BECWidget, QWidget):
|
||||
"""
|
||||
self.dark_mode_enabled = not self.dark_mode_enabled
|
||||
self.update_mode_button()
|
||||
set_theme("dark" if self.dark_mode_enabled else "light")
|
||||
apply_theme("dark" if self.dark_mode_enabled else "light")
|
||||
|
||||
def update_mode_button(self):
|
||||
icon = material_icon(
|
||||
@@ -100,7 +100,7 @@ class DarkModeButton(BECWidget, QWidget):
|
||||
if __name__ == "__main__":
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
apply_theme("dark")
|
||||
w = DarkModeButton()
|
||||
w.show()
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
(api_reference)=
|
||||
# API Reference
|
||||
|
||||
This page contains the auto-generated API documentation for all modules, classes, and functions in the BEC Widgets package.
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:toctree: _autosummary
|
||||
:template: custom-module-template.rst
|
||||
:recursive:
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
:caption: API Documentation
|
||||
bec_widgets
|
||||
|
||||
../autoapi/bec_widgets/index
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 498 KiB |
42
docs/conf.py
42
docs/conf.py
@@ -32,15 +32,16 @@ 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 = [
|
||||
@@ -59,15 +60,7 @@ myst_enable_extensions = [
|
||||
"tasklist",
|
||||
]
|
||||
|
||||
# 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"
|
||||
|
||||
autosummary_generate = True # Turn on sphinx.ext.autosummary
|
||||
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
|
||||
@@ -87,30 +80,3 @@ html_theme = "pydata_sphinx_theme"
|
||||
html_static_path = ["_static"]
|
||||
html_css_files = ["custom.css"]
|
||||
html_logo = "../bec_widgets/assets/app_icons/bec_widgets_icon.png"
|
||||
|
||||
|
||||
def skip_submodules(app, what, name, obj, skip, options):
|
||||
if what == "module":
|
||||
if not name.startswith("bec_widgets"):
|
||||
skip = True
|
||||
# print(f"Checking module: {name}")
|
||||
if "bec_widgets.widgets" in name:
|
||||
widget = name.split(".")[-2]
|
||||
submodule = name.split(".")[-1]
|
||||
if submodule in [f"register_{widget}", f"{widget}_plugin"]:
|
||||
# print(f"Skipping submodule: {name}")
|
||||
skip = True
|
||||
elif what in ["data", "attribute"]:
|
||||
obj_name = name.split(".")[-1]
|
||||
if obj_name.startswith("_") or obj_name in ["__all__", "logger", "bec_logger", "app"]:
|
||||
skip = True
|
||||
|
||||
elif what == "class":
|
||||
class_name = name.split(".")[-1]
|
||||
if class_name.startswith("Demo"):
|
||||
skip = True
|
||||
return skip
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.connect("autoapi-skip-member", skip_submodules)
|
||||
|
||||
@@ -10,5 +10,5 @@ We offer up to three different options for composing larger GUIs from these modu
|
||||
## Client-Server Architecture
|
||||
|
||||
BEC Widgets is built on top of the [BEC](https://bec.readthedocs.io/en/latest/) package, which provides the backend for beamline experiment control. BEC Widgets is a client of BEC, meaning it can interact with the backend through a client-server architecture. To make full usage of the available features of BEC, we recommend to check the documentation about [data access](https://bec.readthedocs.io/en/latest/developer/data_access/data_access.html) in which the messaging and event system of BEC is described.
|
||||
In the context of BEC Widgets, the {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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.11 or higher. Verify your Python version to ensure compatibility.
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
|
||||
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
3. **Qt Distributions:** BEC Widgets supports [PySide6](https://doc.qt.io/qtforpython-6/quickstart.html) and [PyQt6](https://www.riverbankcomputing.com/static/Docs/PyQt6/introduction.html). We use [qtpy](https://pypi.org/project/QtPy/) to abstract the underlying QT distribution.
|
||||
|
||||
|
||||
@@ -7,5 +7,4 @@ sphinx-copybutton
|
||||
sphinx-inline-tabs
|
||||
myst-parser
|
||||
sphinx-design
|
||||
sphinx-autoapi
|
||||
tomli
|
||||
@@ -1,10 +1,11 @@
|
||||
(user.api_reference)=
|
||||
# User API Reference
|
||||
|
||||
This section contains the API documentation for the main user-facing modules and classes.
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:toctree: _autosummary
|
||||
:template: custom-module-template.rst
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 2
|
||||
bec_widgets.cli.client
|
||||
|
||||
../../autoapi/bec_widgets/cli/index
|
||||
```
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Before installing BEC Widgets, please ensure the following requirements are met:
|
||||
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.11 or higher. Verify your Python version to ensure compatibility.
|
||||
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
|
||||
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
|
||||
|
||||
**Standard Installation**
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
In order to use BEC Widgets as a plotting tool for BEC, it needs to be [installed](#user.installation) in the same Python environment as the BEC IPython client (please refer to the [BEC documentation](https://bec.readthedocs.io/en/latest/user/command_line_interface.html#start-up) for more details). Upon startup, the client will automatically launch a GUI and store it as a `gui` object in the client. The GUI backend will also be automatically connect to the BEC server, giving access to all information on the server and allowing the user to visualize the data in real-time.
|
||||
|
||||
## BECGuiClient
|
||||
The `gui` object is the main entry point for interacting with the BEC Widgets framework. It is an instance of the {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.
|
||||
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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```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 {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.
|
||||
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.
|
||||
|
||||
From the CLI, you can create new docks like this:
|
||||
|
||||
@@ -34,23 +34,23 @@ dock = gui.new().new()
|
||||
 -->
|
||||
|
||||
## Widgets
|
||||
Widgets are the building blocks of the BEC Widgets framework. They are the visual components that allow users to interact with the data and control the behavior of the application. Each dock can contain multiple widgets, albeit we recommend for most use cases a single widget per dock. BEC Widgets provides a set of core widgets (cf. {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.
|
||||
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.
|
||||
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 {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.
|
||||
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.
|
||||
|
||||
```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 (`title`), axis labels (`x_label`)
|
||||
<!-- or limits (`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 ([`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)). -->
|
||||
|
||||
We invite you to explore the API of the WaveForm in the {ref}`user.widgets.waveform_1d` or directly in the command line.
|
||||
We invite you to explore the API of the WaveForm in the [documentation](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 {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`.
|
||||
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).
|
||||
|
||||
**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`.
|
||||
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).
|
||||
|
||||
**Image Plot**
|
||||
|
||||
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`.
|
||||
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).
|
||||
|
||||
### 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 {ref}`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 [API documentation](user.api_reference), but also by using BEC Widgets, exploring the available functions and check their dockstrings.
|
||||
```python
|
||||
gui.new? # shows the dockstring of the new method
|
||||
```
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
```{tab} Overview
|
||||
|
||||
The {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.
|
||||
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.
|
||||
|
||||
## Key Features:
|
||||
- **Modern Design**: The BEC Progressbar widget is designed with a modern and sleek appearance, following the BEC theme.
|
||||
@@ -35,8 +35,6 @@ pb.set_value(50)
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. autoclass:: bec_widgets.cli.client.BECProgressBar
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECProgressBar.rst
|
||||
```
|
||||
````
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Key Features:
|
||||
- **Comprehensive Service Monitoring**: Track the state of individual BEC services, including real-time updates on their health and status.
|
||||
@@ -33,8 +33,6 @@ Once the `BECStatusBox` is added, users can interact with it to view the status
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. autoclass:: bec_widgets.cli.client.BECStatusBox
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox.rst
|
||||
```
|
||||
````
|
||||
@@ -146,14 +146,8 @@ my_gui.show()
|
||||
|
||||
````{tab} API
|
||||
```{eval-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:
|
||||
.. 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
|
||||
```
|
||||
````
|
||||
@@ -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,21 +66,12 @@ app.exec_()
|
||||
```
|
||||
|
||||
`ResumeButton`, `ResetButton`, and `AbortButton` may be used in an exactly analogous way.
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-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:
|
||||
.. 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
|
||||
```
|
||||
````
|
||||
`````
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Key Features:
|
||||
- **Select DAP model**: Select one of the available DAP models.
|
||||
@@ -30,6 +30,11 @@ The following slots are available for the `DAP ComboBox` widget:
|
||||
- `select_y_axis(str)` : Slot to select the current y axis, emits the `x_axis_updated` signal
|
||||
- `select_fit_model(str)` : Slot to select the current fit model, emits the `fit_model_updated` signal. If x and y axis are set, it will also emit the `new_dap_config` signal.
|
||||
````
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.widgets.dap_combo_box.dap_combo_box.DAPCombobox.rst
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The {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.
|
||||
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.
|
||||
|
||||
```{note}
|
||||
The `Device Browser` widget is currently under development. Other widgets may not support drag and drop functionality yet.
|
||||
@@ -34,8 +34,6 @@ dock_area.device_browser.DeviceBrowser
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. autoclass:: bec_widgets.cli.client.DeviceBrowser
|
||||
:members:
|
||||
:show-inheritance:
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.DeviceBrowser.rst
|
||||
```
|
||||
````
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user