1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 01:37:53 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
b9eff4eecf wip 2025-07-10 14:21:42 +02:00
259 changed files with 1233 additions and 15491 deletions

View File

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

View File

@@ -1,64 +0,0 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
BEC_CORE_BRANCH:
description: 'Branch for BEC Core'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch for Ophyd Devices'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch for BEC Widgets'
required: false
default: 'main'
type: string
jobs:
bec:
name: BEC Unit Tests
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Install BEC and dependencies
uses: ./.github/actions/bec_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: '3.11'
- name: Run Pytest
run: |
cd ./bec
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
bec-e2e-test:
name: BEC End2End Tests
runs-on: ubuntu-latest
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Run E2E Tests
uses: ./.github/actions/bec_e2e_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
PYTHON_VERSION: '3.11'

View File

@@ -57,24 +57,4 @@ jobs:
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml
child-repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
uses: ./.github/workflows/end2end-conda.yml

View File

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

View File

@@ -2,14 +2,10 @@ 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:

289
.gitlab-ci.yml Normal file
View 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/

View File

@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.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.

View File

@@ -1,730 +1,6 @@
# CHANGELOG
## v2.41.1 (2025-10-15)
### Bug Fixes
- **dependencies**: Bec lib versions fixed
([`3941050`](https://github.com/bec-project/bec_widgets/commit/3941050883a791f800ab7178af2435ac14f837b6))
## v2.41.0 (2025-10-15)
### Bug Fixes
- **image_roi**: Delete button added to compact version
([`ef27de4`](https://github.com/bec-project/bec_widgets/commit/ef27de40ceee8375d95a0f3a8e451b7d05d0ae2c))
- **image_roi**: Rois can be removed with right click context menu
([`37df95e`](https://github.com/bec-project/bec_widgets/commit/37df95ead8d6a07a6c5794a97a486d9f380004cc))
### Build System
- **bec_lib**: Version bump to 3.69.3
([`28ac9c5`](https://github.com/bec-project/bec_widgets/commit/28ac9c5cc369bdfa712c70c45591243631c65066))
### Features
- **image_roi_tree**: Compact mode added
([`c87a6cf`](https://github.com/bec-project/bec_widgets/commit/c87a6cfce9c36588b32f5279e63072bc2646c36f))
### Refactoring
- **serializer**: Upgrade to new serializer interface
([`3d807ea`](https://github.com/bec-project/bec_widgets/commit/3d807eaa63980fd2bb11661696c4d8548fffde8c))
### Testing
- **deviceconfig-form-update**: Add onFailure default to test
([`1dd20d5`](https://github.com/bec-project/bec_widgets/commit/1dd20d5986485f3bfe7ee02596ca23027ec4b756))
## v2.40.0 (2025-10-08)
### Bug Fixes
- **curve_tree**: Fetching scan numbers directly from the bec client
([`8111a4a`](https://github.com/bec-project/bec_widgets/commit/8111a4a21b7c1bd75316e9a1f1166b88ea52326d))
- **curve_tree**: Safeguard fetching scan numbers from BEC client
([`df8065e`](https://github.com/bec-project/bec_widgets/commit/df8065ea4000b24235520756515aa18f812bb390))
- **curve_tree**: Scans are always fetched by scan ids
([`20a59af`](https://github.com/bec-project/bec_widgets/commit/20a59af648a9808057df2226a3a3c12893cc5059))
- **waveform**: Cleanup of scan_history dialog if not closed manually before widget
([`d681ba5`](https://github.com/bec-project/bec_widgets/commit/d681ba538be9ccec45a1ebd412cbc33c8c7c0ae2))
- **waveform**: Fetching scan number is not done from list but from .get_by_scan_number
([`962ab77`](https://github.com/bec-project/bec_widgets/commit/962ab774e6afc73a321a5680e2862d9e41812888))
- **waveform**: If scan id and scan number is provided, the scan is fetched from the scan id
([`e59f27a`](https://github.com/bec-project/bec_widgets/commit/e59f27a22de490768c814c80642a7a91bebfef5b))
- **waveform**: Safeguard added to the fetching history data
([`540cfc3`](https://github.com/bec-project/bec_widgets/commit/540cfc37be65afcf721773564adc85de681a9d07))
- **waveform**: Safeguard for _scan_history_closed
([`2bf4896`](https://github.com/bec-project/bec_widgets/commit/2bf489600e96bb5b47d89bed261614f62c970ca9))
- **waveform**: Safeguard for if scan_item is a list
([`7e88a00`](https://github.com/bec-project/bec_widgets/commit/7e88a002b6ca40fc85fde993282b8706f140d9aa))
- **waveform**: Update x suffix label with x property change, do not wait for next update cycle
([`d19001c`](https://github.com/bec-project/bec_widgets/commit/d19001c94e652c0c3e18f8d7903fd1ccff1111cd))
- **waveform**: X_data checked with is scalar instead of len()
([`db7dd4f`](https://github.com/bec-project/bec_widgets/commit/db7dd4f8d4b1210e65c852f6193fc8cf0f4809a5))
### Build System
- **bec_lib**: Bec_lib dependency raised to 3.68
([`2f3dc2c`](https://github.com/bec-project/bec_widgets/commit/2f3dc2ce6b7133fc5582bd6996a674590cf1002d))
### Chores
- Add dependabot config
([`f25f865`](https://github.com/bec-project/bec_widgets/commit/f25f86522f0a2e9dd24ca862ea8de89873951f83))
### Features
- **waveform**: New type of curve - history curve
([`f083dff`](https://github.com/bec-project/bec_widgets/commit/f083dff6128c6256443b49f54ab12b54f1b90d66))
### Refactoring
- **test_waveform**: Test waveform renamed
([`2f798be`](https://github.com/bec-project/bec_widgets/commit/2f798be7b0d43d304ccbd0e992a9d62f1aa1dd5f))
- **waveform**: Separate method to fetch scan item from history
([`4be7058`](https://github.com/bec-project/bec_widgets/commit/4be70580a60293204b135c6ea77978f1dcf8aa5f))
### Testing
- **conftest**: Suppress_message_box for error popups fixture autouse True
([`0844a9e`](https://github.com/bec-project/bec_widgets/commit/0844a9e11975a34780b1dc413f5145517d1a1a22))
- **plotting_framework_e2e**: Fetching history curve
([`a006f95`](https://github.com/bec-project/bec_widgets/commit/a006f95f211ad115019967e365a6627d9678a1e3))
- **waveform,curve_tree**: Test extended to cover history curve behaviour
([`5a5d323`](https://github.com/bec-project/bec_widgets/commit/5a5d32312b08e1edeb69243daddfaaa9bac22273))
## v2.39.1 (2025-10-07)
### Bug Fixes
- Explicitly pass the cached readout flag
([`50696bc`](https://github.com/bec-project/bec_widgets/commit/50696bce4ce14c61b4bdda8c6fb40967972e6b23))
## v2.39.0 (2025-09-24)
### Bug Fixes
- **rpc**: Fix hide/show
([`975404f`](https://github.com/bec-project/bec_widgets/commit/975404f483ddae041d9f4d819f39c53cec191439))
### Features
- **rpc_base**: Windows can be raised to front from CLI
([`565c0bd`](https://github.com/bec-project/bec_widgets/commit/565c0bd1e7f4684d8401b6a2827c35422b1125c4))
## v2.38.4 (2025-09-23)
### Bug Fixes
- **image**: Add support for specifying preview signals through cli
([`108ddae`](https://github.com/bec-project/bec_widgets/commit/108ddae6ca3501a57b499c7080a36cf41a653074))
## v2.38.3 (2025-09-23)
### Bug Fixes
- **connector**: Only flush pending events
([`475ca9f`](https://github.com/bec-project/bec_widgets/commit/475ca9f2d81bcc2bb0c7b104c0712b13d6616c08))
- **ringprogressbar**: Fix client signature
([`65bc5f5`](https://github.com/bec-project/bec_widgets/commit/65bc5f5421077da70ef5068d51e36119e1055955))
- **ringprogressbar**: Various fixes and improvements
([`bbb5fc6`](https://github.com/bec-project/bec_widgets/commit/bbb5fc6ce17248a948c6fd4a7652d17d64a79d2a))
### Chores
- Deprecate 3.10, add 3.13
([`3e33934`](https://github.com/bec-project/bec_widgets/commit/3e339348dd3d0a3b12522312132fca139dc22835))
### Testing
- **ringprogressbar**: Extend e2e test
([`b1b6c5e`](https://github.com/bec-project/bec_widgets/commit/b1b6c5e6a5dd81965baa5c742e9bdae8cdb4f09b))
## v2.38.2 (2025-09-11)
### Bug Fixes
- **crosshair**: Ignore fetching data and markers from invisible items
([`72b6f74`](https://github.com/bec-project/bec_widgets/commit/72b6f74252e1f36339945c549049b166cccf3561))
- **plot_base**: Crosshair items are excluded from visible curves and from auto_range
([`4dc4ede`](https://github.com/bec-project/bec_widgets/commit/4dc4ede1d251d081e5bcf3d37fcc784982c9258e))
- **plot_base**: Visible items injected into plot item
([`b703b37`](https://github.com/bec-project/bec_widgets/commit/b703b37bbdbf97182b58ac4c69c1384fa78d0c12))
- **waveform**: Changing curve visibility refresh markers
([`556832f`](https://github.com/bec-project/bec_widgets/commit/556832fd48bcb16b95df8cf91417d7045bbca2a3))
### Continuous Integration
- Fix stale issues job permissions; add workflow dispatch option
([`fe67a4f`](https://github.com/bec-project/bec_widgets/commit/fe67a4f325cbd41f13102e5698d86ed9e90b048e))
### Documentation
- Move to autoapi
([`18ef35f`](https://github.com/bec-project/bec_widgets/commit/18ef35f22a1b7496b13f833e63a4f3875e1497e3))
### Testing
- **crosshair**: Visibility test added with plotbase fixture
([`3a2ec9f`](https://github.com/bec-project/bec_widgets/commit/3a2ec9f1b74c4bb5f239940b874576a877ce45c0))
## v2.38.1 (2025-08-22)
### Bug Fixes
- Move thefuzz dependency to prod
([`ad7cdc6`](https://github.com/bec-project/bec_widgets/commit/ad7cdc60dd6da6c5291f8b42932aacb12aa671a6))
## v2.38.0 (2025-08-19)
### Features
- **device_manager**: Devicemanager view of config session
([`6e05157`](https://github.com/bec-project/bec_widgets/commit/6e05157abb61ec569eec10ff7295c28cb6a2ec45))
## v2.37.0 (2025-08-19)
### Features
- Add explorer widget
([`1bec9bd`](https://github.com/bec-project/bec_widgets/commit/1bec9bd9b2238ed484e8d25e691326efe5730f6b))
## v2.36.0 (2025-08-18)
### Features
- **scan control**: Add support for literals
([`f2e5a85`](https://github.com/bec-project/bec_widgets/commit/f2e5a85e616aa76d4b7ad3b3c76a24ba114ebdd1))
## v2.35.0 (2025-08-14)
### Build System
- Pyside6 upgraded to 6.9.0
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
### Features
- **property_manager**: Property manager widget
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
## v2.34.0 (2025-08-07)
### Bug Fixes
- Plugin widget import machinery
([`9de5484`](https://github.com/bec-project/bec_widgets/commit/9de548446b9975c0f692757c66ffa07b9a849f15))
- lazy import client so plugin widgets can import BECWidgets which use it indirectly - exclude
classes originating from bec_widgets core from plugin discovery - better errors
- Use better source for plugin repo name
([`f4af6eb`](https://github.com/bec-project/bec_widgets/commit/f4af6ebc5fabf5b62ec87b580476d93d52690b08))
### Features
- Autoformat compiled file and add docs
([`a923f12`](https://github.com/bec-project/bec_widgets/commit/a923f12c974192909222fcada9eca97325866d74))
- **plugin manager**: Add cli commands
([`49ac7de`](https://github.com/bec-project/bec_widgets/commit/49ac7decf7d4cf461e6437f7285dc6967ee36d96))
## v2.33.3 (2025-07-31)
### Bug Fixes
- **scan-history-view**: Account for async loading of scan history
([`6df1d0c`](https://github.com/bec-project/bec_widgets/commit/6df1d0c31fb58c25b01e95e2247277ff2dd5d00e))
### Refactoring
- Improve scan history performance on loading full scan lists
([`a5adf3a`](https://github.com/bec-project/bec_widgets/commit/a5adf3a97d9ff05cef833445c1e6cd8f35a9a2fa))
- Make ids a set, cleanup
([`c1f62ad`](https://github.com/bec-project/bec_widgets/commit/c1f62ad6cb00d9b392a8e0b6247f5260dfb37256))
- Use client callback for scan history reload
([`d22a331`](https://github.com/bec-project/bec_widgets/commit/d22a3317baeccfcc4e074dcef4e3912301d210c5))
- **scan-history**: Add spinner for loading time of history
([`50c84a7`](https://github.com/bec-project/bec_widgets/commit/50c84a766a2b021768fb2c0e8ee00b8e5f058ba7))
- **scan-history**: Fix insert logic; cleanup
([`946752a`](https://github.com/bec-project/bec_widgets/commit/946752a4b05804c2f59cb5c21e4c1d11709a7d44))
## v2.33.2 (2025-07-31)
### Bug Fixes
- Delete choice dialog on close
([`23413cf`](https://github.com/bec-project/bec_widgets/commit/23413cffabe721e35bb5bb726ec34d74dc4ffe05))
- Display short lists in SignalDisplay
([`4bbb8fa`](https://github.com/bec-project/bec_widgets/commit/4bbb8fa519e8a90eebfcfa34e157493c9baa7880))
- Don't warn on empty DeviceEdit init
([`f18eeb9`](https://github.com/bec-project/bec_widgets/commit/f18eeb9c5dccbd9348b6ee6d1477a8b7925d40fc))
- Remove config, directly set device+signal
([`32ce8e2`](https://github.com/bec-project/bec_widgets/commit/32ce8e2818ceacda87e48399e3ed4df0cabb2335))
## v2.33.1 (2025-07-31)
### Bug Fixes
- **cli**: Ensure guis are not started twice
([`cd81e7f`](https://github.com/bec-project/bec_widgets/commit/cd81e7f9ba40be23f6b930d250f743276720b277))
## v2.33.0 (2025-07-29)
### Bug Fixes
- **monaco**: Forward text changed signal
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
### Build System
- Update bec and qtmonaco min dependencies
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
### Features
- **monaco**: Add insert, delete and lsp header
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
- **monaco**: Add vim mode
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
- **web console**: Add set_readonly method
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
- **web console**: Add signal to indicate when the js backend is initialized
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
### Testing
- **web console**: Add tests for the web console
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
## v2.32.0 (2025-07-29)
### Features
- **dock area**: Add screenshot toolbar action
([`fd5af01`](https://github.com/bec-project/bec_widgets/commit/fd5af0184279400ca6d8e5d2042f31be88d180f3))
- **rpc_timeout**: Add decorator to override the rpc timeout
([`8a214c8`](https://github.com/bec-project/bec_widgets/commit/8a214c897899d0d94d5f262591a001c127d1b155))
## v2.31.3 (2025-07-29)
### Bug Fixes
- **waveform**: Fallback mechanism for auto mode to use index if scan_report_devices are not
available
([`6bf84ae`](https://github.com/bec-project/bec_widgets/commit/6bf84aea2508ff01fe201c045ec055684da88593))
## v2.31.2 (2025-07-29)
### Bug Fixes
- **bec widgets**: Always call cleanup of child widgets on cleanup
([`bf86a03`](https://github.com/bec-project/bec_widgets/commit/bf86a030a08b325a08e031ff71d0716a2f2f122b))
## v2.31.1 (2025-07-29)
### Bug Fixes
- **image_base**: Fix cleanup of uninitialized image layer
([`c1bdc50`](https://github.com/bec-project/bec_widgets/commit/c1bdc506e8099f178acdccbe0e1109deeeaaca38))
## v2.31.0 (2025-07-29)
### Bug Fixes
- **bec_main_window**: Main window have unified status bar on macOS
([`1d0490f`](https://github.com/bec-project/bec_widgets/commit/1d0490fff428d51f2cdb7d35a954a7cd62cbb65c))
- **color_button_native**: Removed BECWidget inheritance
([`e42ffd7`](https://github.com/bec-project/bec_widgets/commit/e42ffd7c015a026d8e0967ac6b5866cbbea7bfed))
- **decimal_spinbox**: Removed BECWidget inheritance
([`2bd6d00`](https://github.com/bec-project/bec_widgets/commit/2bd6d0089955172134afb4d39939890026ed43f0))
- **launch_window**: Logic for custom main window apps adjusted
([`e090ac4`](https://github.com/bec-project/bec_widgets/commit/e090ac49b72fa15ebf1c09164ff3c6de577cb939))
- **plugin_utils**: Plugins can be created from QWidgets, no need for BECWidget base class for
plugin creation
([`c2a918e`](https://github.com/bec-project/bec_widgets/commit/c2a918ef4b77ccd7fa43d1bc0b907d55a17a6c95))
- **scan_progressbar**: Added kwargs to init
([`7073e75`](https://github.com/bec-project/bec_widgets/commit/7073e75adf0eeb81f4f8e27eb99fc1b7a395c751))
- **utils**: Plugin template createWidget do not initialise widgets by default
([`728d4ef`](https://github.com/bec-project/bec_widgets/commit/728d4efd9646ffcecd7d1a2f70988a7d7c799124))
- **widgets**: Added missing __init__ files
([`6bbf512`](https://github.com/bec-project/bec_widgets/commit/6bbf5126cf586063ed08d6cd489d6a9af28eac35))
### Features
- **bec_main_window**: Plugin and rpc created
([`e4521d9`](https://github.com/bec-project/bec_widgets/commit/e4521d95286bbc598c3c05f357d247d950477b71))
### Refactoring
- **widgets**: All plugins regenerated
([`10cbb9a`](https://github.com/bec-project/bec_widgets/commit/10cbb9a05cb96a791448caff4ffc4115b76146d7))
### Testing
- **launch_window**: Mainwindow raise test removed, features is supported now
([`0854175`](https://github.com/bec-project/bec_widgets/commit/0854175acbda1d4de71358aec028539552a26448))
## v2.30.6 (2025-07-26)
### Bug Fixes
- **waveform**: Autorange is applied with 150ms delay after curve is added
([`61e5bde`](https://github.com/bec-project/bec_widgets/commit/61e5bde15f0e1ebe185ddbe81cd71ad581ae6009))
## v2.30.5 (2025-07-25)
### Bug Fixes
- **positioner-box**: Test to fix handling of none integer values for precision
([`b718b43`](https://github.com/bec-project/bec_widgets/commit/b718b438bacff6eb6cd6015f1a67dcf75c05dce4))
### Refactoring
- **positioner-box**: Cleanup, accept float precision
([`4d5df96`](https://github.com/bec-project/bec_widgets/commit/4d5df9608a9438b9f6d7508c323eb3772e53f37d))
## v2.30.4 (2025-07-25)
### Bug Fixes
- **cli**: Remove stderr from cli output when not using rpc
([`b4e0664`](https://github.com/bec-project/bec_widgets/commit/b4e0664011682cae9966aa2632210a6b60e11714))
## v2.30.3 (2025-07-23)
### Bug Fixes
- Cleanup subscriptions in device browser
([`0d81bdd`](https://github.com/bec-project/bec_widgets/commit/0d81bdd4ddb4ec474a414b107cbc7fc865253934))
## v2.30.2 (2025-07-23)
### Bug Fixes
- Factor out device name function and add test
([`8eb8225`](https://github.com/bec-project/bec_widgets/commit/8eb8225a7f56014d6093aa142b3a5d071837982e))
- **rpc_base**: Rpc_call wrapper passes full_name for Devices indeed of name
([`491d044`](https://github.com/bec-project/bec_widgets/commit/491d04467c8ce4e116d61e614895d1dcc6b4b201))
### Testing
- **test_plotting_framework_e2e**: Added test for waveform with passing device from dev container
([`3fd09fc`](https://github.com/bec-project/bec_widgets/commit/3fd09fceef2ffa7e7c3eee20176304bafb00d0db))
## v2.30.1 (2025-07-22)
### Bug Fixes
- Ignore KeyError in SignalLabel
([`608590c`](https://github.com/bec-project/bec_widgets/commit/608590c5421368d5bba0e4b0f5187d90cac323be))
## v2.30.0 (2025-07-22)
### Bug Fixes
- **device_browser**: Display signal for signals
([`3384ca0`](https://github.com/bec-project/bec_widgets/commit/3384ca02bdb5a2798ad3339ecf3e2ba7c121e28f))
- **device_signal_display**: Don't read omitted
([`b9af36a`](https://github.com/bec-project/bec_widgets/commit/b9af36a4f1c91e910d4fc738b17b90e92287a7e3))
- **signal_label**: Rewrite reading selection logic
([`cd17a4a`](https://github.com/bec-project/bec_widgets/commit/cd17a4aad905296eb0460ecc27e5920f5c2e8fe5))
- **signal_label**: Show all signals by default
([`22beadc`](https://github.com/bec-project/bec_widgets/commit/22beadcad061b328c986414f30fef57b64bad693))
- **signal_label**: Update signal from dialog correctly
([`959cedb`](https://github.com/bec-project/bec_widgets/commit/959cedbbd5a123eef5f3370287bf6476c48caab9))
- **signal_label**: Use read() instead of get() for init
([`f0dc992`](https://github.com/bec-project/bec_widgets/commit/f0dc99258607a5cc8af51686d01f7fd54ae2779f))
### Chores
- Update client.py
([`fd1f994`](https://github.com/bec-project/bec_widgets/commit/fd1f9941e046b7ae1e247dde39c20bcbc37ac189))
### Features
- **signal_label**: Property to display array data or not
([`ca4f975`](https://github.com/bec-project/bec_widgets/commit/ca4f97503bf06363e8e8a5d494a9857223da4104))
## v2.29.0 (2025-07-22)
### Features
- **notification_banner**: Notification centre for alarms implemented into BECMainWindow
([`cd9d22d`](https://github.com/bec-project/bec_widgets/commit/cd9d22d0b40d633af76cb1188b57feb7b6a5dbf2))
### Refactoring
- **notification_banner**: Becnotificationbroker done as singleton to sync all windows in the
session
([`7cda2ed`](https://github.com/bec-project/bec_widgets/commit/7cda2ed846d3c27799f4f15f6c5c667631b1ca55))
## v2.28.0 (2025-07-21)
### Features
- Disable editing while scan active
([`1397655`](https://github.com/bec-project/bec_widgets/commit/13976557fbdb71a1161029521d81a655d25dd134))
- Remove and readd device for config changes
([`8489ef4`](https://github.com/bec-project/bec_widgets/commit/8489ef4a69d69b39648b1a9270012f14f95c6121))
- Save and load config from devicebrowser
([`7f0098f`](https://github.com/bec-project/bec_widgets/commit/7f0098f1533d419cc75801c4d6cbea485c7bbf94))
## v2.27.1 (2025-07-17)
### Bug Fixes
- **image_roi_tree**: Rois signals are disconnected when roi tree widget is closed
([`00e3713`](https://github.com/bec-project/bec_widgets/commit/00e3713181916a432e4e9dec8a0d80205914cf77))
## v2.27.0 (2025-07-17)
### Features
- Add monaco editor
([`2373c7e`](https://github.com/bec-project/bec_widgets/commit/2373c7e996566a5b84c5a50e1c3e69de885713db))
## v2.26.0 (2025-07-17)
### Bug Fixes
- **config label**: Reset offset when toggling the label action
([`b8ae7b2`](https://github.com/bec-project/bec_widgets/commit/b8ae7b2e96071b6dc59dae7ffa72bbedc6aaea23))
- **performance_bundle**: Fix performance bundle cleanup
([`23674cc`](https://github.com/bec-project/bec_widgets/commit/23674ccf592a2caa0b57ae64ad1499c270b7d469))
### Features
- **device combobox**: Add option to insert an empty element
([`debd347`](https://github.com/bec-project/bec_widgets/commit/debd347b64a3d2ca07ddcd5ef3a3394d1ffb67e3))
- **heatmap**: Add interpolation and oversampling UI components
([`1d8069e`](https://github.com/bec-project/bec_widgets/commit/1d8069e391412e3096a3c1e7181398dd4e609650))
### Refactoring
- **image_base**: Cleanup
([`46a9178`](https://github.com/bec-project/bec_widgets/commit/46a91784d237137128965ad585e38085e931e5d4))
### Testing
- **history**: Add history message helper methods to conftest
([`44cc061`](https://github.com/bec-project/bec_widgets/commit/44cc06137ccfbc087bdd3005156ff28effe05f23))
## v2.25.0 (2025-07-17)
### Bug Fixes
- **bec-progressbar**: Add flag for theme update
([`694a6c4`](https://github.com/bec-project/bec_widgets/commit/694a6c49608b68e25dc0c76b58855b96f3f0ef0b))
### Continuous Integration
- **bec**: Add child_repos test for bec (unit and e2e tests)
([`a9c8995`](https://github.com/bec-project/bec_widgets/commit/a9c8995ac0b39f6bc327887f43f7d4d6e6e89db2))
- **plugin**: Add plugin repository test to BW ci
([`2b06e34`](https://github.com/bec-project/bec_widgets/commit/2b06e34ecff8c0a92a2b235f375e837729736b2a))
### Features
- **scan-history-browser**: Add history browser and history metadata viewer
([`9caae4c`](https://github.com/bec-project/bec_widgets/commit/9caae4cf40d3876175b827abb735ae227ae0bcea))
### Refactoring
- Add additional components for history metadata, device view and popup ui
([`cf97cc1`](https://github.com/bec-project/bec_widgets/commit/cf97cc1805e16073c7849d1f9375e2ebd2176b70))
- Cleanup, add compact popup view for scan_history_browser and update tests
([`25b2737`](https://github.com/bec-project/bec_widgets/commit/25b2737aacfaa45f255afb6ebf467d5781165a8e))
## v2.24.1 (2025-07-15)
### Bug Fixes
- Update signal label for device_edit changes
([`bde5238`](https://github.com/bec-project/bec_widgets/commit/bde523806fdb6ab224b485f65b615f89dfe20b7b))
## v2.24.0 (2025-07-15)
### Bug Fixes
- Hide validity LED, show message as tooltip
([`530797a`](https://github.com/bec-project/bec_widgets/commit/530797a5568957dde9f47f417310f5c4d2493906))
- Validate some config data
([`c660e51`](https://github.com/bec-project/bec_widgets/commit/c660e5141f191a782c224ee1b83536793639eecb))
- **device_browser**: Un-nest exception
([`8b2d605`](https://github.com/bec-project/bec_widgets/commit/8b2d6052e808f8b4063e5f45c40e4460524f044e))
### Documentation
- Docstring for config dialog
([`1925e6a`](https://github.com/bec-project/bec_widgets/commit/1925e6ac7f98875eb5980637ae3293e22b459e28))
### Features
- (#495) add devices through browser
([`170be0c`](https://github.com/bec-project/bec_widgets/commit/170be0c7d3bb1f6e5f2305958909ef68cd987fbd))
- **#495**: Add validation against existing device names
([`900153b`](https://github.com/bec-project/bec_widgets/commit/900153bc0b8cec7bad82e23b3772c66e84900a17))
- **device_browser**: Connect update to item refresh
([`130cc24`](https://github.com/bec-project/bec_widgets/commit/130cc24b351684358558ab81c0111f10f9abb11f))
- **device_browser**: Device deletion from config
([`8dc7265`](https://github.com/bec-project/bec_widgets/commit/8dc72656ef46ae7be886f9da59beb768f5381b4f))
## v2.23.0 (2025-07-11)
### Features
- **widget_finder**: Widget to fetch any other widget by class from currently running app
([`a9fce17`](https://github.com/bec-project/bec_widgets/commit/a9fce175b720ad85a5cefcab99d79fbcb971ff4a))
- **widget_io**: Utility function to find widget in the app by class
([`783d042`](https://github.com/bec-project/bec_widgets/commit/783d042e8c469774fc8407921462a99c96f6d408))
## v2.22.2 (2025-07-11)
### Bug Fixes
- **plot_base**: Autorange takes into account only visible curves
([`7643986`](https://github.com/bec-project/bec_widgets/commit/76439866c1fd09cb7d9d48dfccdc7b1943bfbc0f))
## v2.22.1 (2025-07-11)
### Bug Fixes
- **crosshair**: Crosshair mouse_moved can be set manually
([`63a8da6`](https://github.com/bec-project/bec_widgets/commit/63a8da680d263a50102aacf463ec6f6252562f9d))
- **heatmap**: Fix pixel size calculation for arbitrary shapes
([`6c49425`](https://github.com/bec-project/bec_widgets/commit/6c494258f82059a2472f43bb8287390ce1aba704))
## v2.22.0 (2025-07-10)
### Bug Fixes
- **crosshair**: Add slot to reset mouse markers
([`adaad4f`](https://github.com/bec-project/bec_widgets/commit/adaad4f4d5ebf775a337e23a944ba9eb289d01a0))
- **crosshair**: Fix crosshair support for transformations
([`3ba0fc4`](https://github.com/bec-project/bec_widgets/commit/3ba0fc4b442e5926f27a13f09d628c30987f2cf8))
- **image**: Reset crosshair on new scan
([`e594de3`](https://github.com/bec-project/bec_widgets/commit/e594de3ca39970f91f5842693eeb1fac393eaa34))
- **image item**: Fix processor for nans in images
([`39c316d`](https://github.com/bec-project/bec_widgets/commit/39c316d6eadfdfbd483661b67720a7e224a46712))
- **image_base**: Move cbar init to image base
([`8929778`](https://github.com/bec-project/bec_widgets/commit/8929778f073c40a9eabba7eda2415fc9af1072bb))
- **image_processor**: Support for nans in nd arrays
([`a6fc799`](https://github.com/bec-project/bec_widgets/commit/a6fc7993a3d22cfd086310c8e6dad3f9f3d1e9fe))
### Features
- Add heatmap widget
([`0c76b0c`](https://github.com/bec-project/bec_widgets/commit/0c76b0c49598d1456aab266b483de327788028fd))
- **image_item**: Add support for qtransform
([`324a5bd`](https://github.com/bec-project/bec_widgets/commit/324a5bd3d9ed278495c6ba62453b02061900ae32))
## v2.21.4 (2025-07-08)
### Bug Fixes

View File

@@ -5,7 +5,7 @@
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue?logo=python&logoColor=white)](https://www.python.org)
[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)

View File

@@ -31,7 +31,7 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
@@ -395,24 +395,20 @@ class LaunchWindow(BECMainWindow):
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindowNoRPC()
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
"""
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
instantiate it directly; otherwise, embed it in a UILaunchWindow.
"""
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
# Parse the UI to detect top-level widget class
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
@@ -420,22 +416,19 @@ class LaunchWindow(BECMainWindow):
if widget is None:
raise ValueError("No widget found in the UI file.")
# Load the UI into a widget
loader = UILoader(None)
loaded = loader.loader(ui_file)
# Display the UI in a BECMainWindow
if isinstance(loaded, BECMainWindow):
window = loaded
window.object_name = filename
else:
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
window.setWindowTitle(f"BEC - {filename}")
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
@@ -458,7 +451,7 @@ class LaunchWindow(BECMainWindow):
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindowNoRPC()
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)

File diff suppressed because it is too large Load Diff

View File

@@ -14,21 +14,18 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
logger = bec_logger.logger
@@ -54,7 +51,7 @@ def _filter_output(output: str) -> str:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)
@@ -154,10 +151,8 @@ def wait_for_server(client: BECGuiClient):
raise RuntimeError("GUI is not alive")
try:
if client._gui_started_event.wait(timeout=timeout):
if client._gui_started_timer is not None:
# cancel the timer, we are done
client._gui_started_timer.cancel()
client._gui_started_timer.join()
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
@@ -266,37 +261,18 @@ class BECGuiClient(RPCBase):
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
return self._start(wait=wait)
def show(self, wait=True) -> None:
"""
Show the GUI window.
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.
"""
def show(self):
"""Show the GUI window."""
if self._check_if_server_is_alive():
return self._show_all()
return self._start(wait=wait)
return self.start(wait=True)
def hide(self):
"""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,
@@ -406,9 +382,6 @@ class BECGuiClient(RPCBase):
"""
Start the GUI server, and execute callback when it is launched
"""
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
@@ -455,8 +428,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 +439,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):
"""
@@ -564,7 +524,7 @@ if __name__ == "__main__": # pragma: no cover
# Test the client_utils.py module
gui = BECGuiClient()
gui.show(wait=True)
gui.start(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:

View File

@@ -53,7 +53,7 @@ from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -180,10 +180,7 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
@@ -208,26 +205,14 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ...
"""
self.content += f"""
{self._rpc_call(timeout)}"""
self.content += """
@rpc_call"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.

View File

@@ -7,7 +7,6 @@ from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.device import DeviceBaseWithConfig
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
@@ -25,43 +24,6 @@ else:
# pylint: disable=protected-access
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
# if dev.<device> is passed to GUI, it passes full_name
if hasattr(arg, "full_name"):
return arg.full_name
elif hasattr(arg, "name"):
return arg.name
return arg
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.
@@ -85,7 +47,15 @@ def rpc_call(func):
return None # func(*args, **kwargs)
caller_frame = caller_frame.f_back
args, kwargs = _transform_args_kwargs(args, kwargs)
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
@@ -202,11 +172,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 +195,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,

View File

@@ -9,7 +9,6 @@ from contextlib import redirect_stderr, redirect_stdout
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
@@ -143,8 +142,6 @@ class GUIServer:
"""
Shutdown the GUI server.
"""
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()

View File

@@ -55,7 +55,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
"wf": self.wf,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
@@ -105,11 +105,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)

View File

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

View File

@@ -161,6 +161,8 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -184,6 +186,24 @@ class BECConnector:
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.
This method is called when the parent is deleted.
"""
if not hasattr(self, "cleanup"):
return
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
except Exception:
content = traceback.format_exc()
logger.info(
"Failed to run cleanup on deleted parent. "
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
@@ -213,7 +233,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:

View File

@@ -38,11 +38,9 @@ def _loaded_submodules_from_specs(
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
exception_text = "".join(traceback.format_exception(e))
if "(most likely due to a circular import)" in exception_text:
logger.warning(f"Circular import encountered while loading {submodule}")
else:
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
yield submodule
@@ -61,8 +59,7 @@ def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget
and not item.__module__.startswith("bec_widgets"),
and item is not BECWidget,
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)

View File

@@ -1,86 +0,0 @@
import traceback
from pathlib import Path
from typing import Annotated
import copier
import typer
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_repo_path
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
logger = bec_logger.logger
_app = typer.Typer(rich_markup_mode="rich")
def _commit_added_widget(repo: Path, name: str):
git_stage_files(repo, [".copier-answers.yml"])
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
make_commit(repo, f"plugin-manager added new widget: {name}")
logger.info(f"Committing new widget {name}")
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
return name in [w["name"] for w in widget_list]
def _editor_cb(ctx: typer.Context, value: bool):
if value and not ctx.params["use_ui"]:
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
return value
_bold_blue = "\033[34m\033[1m"
_off = "\033[0m"
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
Open the created widget .ui file in bec-designer now?"""
@_app.command()
def widget(
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
open_editor: Annotated[
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
] = True,
):
"""Create a new widget plugin with the given name.
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
bec-designer and the compiled python version will be updated when changes are made and saved."""
if (formatted_name := name.lower().replace("-", "_")) != name:
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
if not formatted_name.isidentifier():
logger.error(
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
)
exit(-1)
logger.info(f"Adding new widget {formatted_name} to the template...")
try:
repo = Path(plugin_repo_path())
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
logger.error(f"Widget {formatted_name} already exists!")
exit(-1)
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
copier.run_update(
repo,
data=plugin_data,
defaults=True,
unsafe=True,
overwrite=True,
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
)
_commit_added_widget(repo, formatted_name)
except Exception:
logger.error(traceback.format_exc())
logger.error("exiting...")
exit(-1)
logger.success(f"Added widget {formatted_name}!")
if open_editor:
open_and_watch_ui_editor(formatted_name)

View File

@@ -1,136 +0,0 @@
import re
import subprocess
from pathlib import Path
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from watchdog.events import (
DirCreatedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEvent,
FileSystemEventHandler,
)
from watchdog.observers import Observer
from bec_widgets.utils.bec_designer import open_designer
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
class RecompileHandler(FileSystemEventHandler):
def __init__(self, in_file: Path, out_file: Path) -> None:
super().__init__()
self.in_file = str(in_file)
self.out_file = str(out_file)
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
self._widget_import_re = re.compile(
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
)
self._widget_modules = {
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
}
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
self.recompile(event)
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
self.recompile(event)
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
self.recompile(event)
def recompile(self, event: FileSystemEvent) -> None:
if event.src_path == self.in_file or event.dest_path == self.in_file:
self._recompile()
def _recompile(self):
logger.success(".ui file modified, recompiling...")
code = subprocess.call(
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
)
logger.success(f"compilation exited with code {code}")
if code != 0:
return
self._add_comment_to_file()
logger.success("updating imports...")
self._update_imports()
logger.success("formatting...")
code = subprocess.call(
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
)
if code != 0:
logger.error(f"Error while running black on {self.out_file}, code: {code}")
return
code = subprocess.call(
[
"isort",
"--line-length=100",
"--profile=black",
"--multi-line=3",
"--trailing-comma",
self.out_file,
]
)
if code != 0:
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
return
logger.success("done!")
def _add_comment_to_file(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
f.write(
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
)
f.write(initial)
def _update_imports(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
qtpy_imports = re.sub(
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
)
print(self._widget_modules)
print(re.findall(self._widget_import_re, qtpy_imports))
widget_imports = re.sub(
self._widget_import_re,
lambda ob: (
f"from {module} import {ob.group(2)}"
if (module := self._widget_modules.get(ob.group(2))) is not None
else ob.group(1)
),
qtpy_imports,
)
f.write(widget_imports)
f.truncate()
def open_and_watch_ui_editor(widget_name: str):
logger.info(f"Opening the editor for {widget_name}, and watching")
repo = Path(plugin_repo_path())
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
ui_file = widget_dir / f"{widget_name}.ui"
ui_outfile = widget_dir / f"{widget_name}_ui.py"
logger.info(
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
)
recompile_handler = RecompileHandler(ui_file, ui_outfile)
observer = Observer()
observer.schedule(recompile_handler, str(ui_file.parent))
observer.start()
try:
open_designer([str(ui_file)])
finally:
observer.stop()
observer.join()
logger.info("Editing session ended, exiting...")

View File

@@ -1,19 +1,15 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
import darkdetect
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
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.rpc_decorator import rpc_timeout
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
@@ -91,7 +87,7 @@ class BECWidget(BECConnector):
theme = "dark"
self.apply_theme(theme)
@SafeSlot(str)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
@@ -100,43 +96,12 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""

View File

@@ -1,6 +1,5 @@
import time
from types import SimpleNamespace
from typing import Literal
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Signal
@@ -40,7 +39,7 @@ class LedLabel(QLabel):
self.setState("default")
self.setFixedSize(20, 20)
def setState(self, state: Literal["success", "default", "warning", "emergency"]):
def setState(self, state: str):
match state:
case "success":
r, g, b, a = self.palette.success.getRgb()
@@ -260,3 +259,12 @@ class CompactPopupWidget(QWidget):
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()
super().closeEvent(event)

View File

@@ -5,13 +5,9 @@ from typing import Any
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, QPointF, Qt, Signal
from qtpy.QtGui import QCursor, QTransform
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.image.image_item import ImageItem
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
@@ -164,7 +160,7 @@ class Crosshair(QObject):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@SafeSlot(str)
@Slot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -191,7 +187,7 @@ class Crosshair(QObject):
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@SafeSlot(int)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
@@ -209,11 +205,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
@@ -272,47 +265,15 @@ class Crosshair(QObject):
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_row.skip_auto_range = True
if item.image_transform is not None:
self.marker_2d_row.setTransform(item.image_transform)
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
if item.image_transform is not None:
self.marker_2d_col.setTransform(item.image_transform)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
@SafeSlot()
def update_markers_on_image_change(self):
"""
Update markers when the image changes, e.g. when the
image shape or transformation changes.
"""
for item in self.items:
if not isinstance(item, pg.ImageItem):
continue
if self.marker_2d_row is not None:
self.marker_2d_row.setSize([item.image.shape[0], 1])
self.marker_2d_row.setTransform(item.image_transform)
if self.marker_2d_col is not None:
self.marker_2d_col.setSize([1, item.image.shape[1]])
self.marker_2d_col.setTransform(item.image_transform)
# Get the current mouse position
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
@@ -355,25 +316,9 @@ class Crosshair(QObject):
image_2d = item.image
if image_2d is None:
continue
# Map scene coordinates (plot units) back to image pixel coordinates
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
xy_trans = inv_transform.map(QPointF(x, y))
else:
xy_trans = QPointF(x, y)
# Define valid pixel coordinate bounds
min_x_px, min_y_px = 0, 0
max_x_px = image_2d.shape[0] - 1 # columns
max_y_px = image_2d.shape[1] - 1 # rows
# Clip the mapped coordinates to the image bounds
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
# Store snapped pixel positions
x_values[name] = px
y_values[name] = py
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
@@ -413,74 +358,60 @@ class Crosshair(QObject):
return list_x[original_index], list_y[original_index]
@SafeSlot(object, tuple)
def mouse_moved(self, event=None, manual_pos=None):
"""
Handles the mouse moved event, updating the crosshair position and emitting signals.
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
Args:
event(object): The mouse moved event, which contains the scene position.
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
event: The mouse moved event
"""
# Determine target (x, y) in *plot* coordinates
if manual_pos is not None:
x, y = manual_pos
else:
if event is None:
return # nothing to do
scene_pos = event[0] # SignalProxy bundle
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
return
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
x, y = view_pos.x(), view_pos.y()
# Update crosshair visuals
self.v_line.setPos(x)
self.h_line.setPos(y)
pos = event[0]
self.update_markers()
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
x, y = mouse_point.x(), mouse_point.y()
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
if snap_x_vals is None or snap_y_vals is None:
return
if all(v is None for v in snap_x_vals.values()) or all(
v is None for v in snap_y_vals.values()
):
return
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
sx, sy = snap_x_vals[name], snap_y_vals[name]
if sx is None or sy is None:
continue
self.marker_moved_1d[name].setData([sx], [sy])
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
self.coordinatesChanged1D.emit(
(name, round(sx_s, precision), round(sy_s, precision))
)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
px, py = snap_x_vals[name], snap_y_vals[name]
if px is None or py is None:
continue
# Respect image transforms
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(px, py, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
self.marker_2d_row.setPos([0, py])
self.marker_2d_col.setPos([px, 0])
self.coordinatesChanged2D.emit((name, px, py))
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -531,35 +462,15 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
"""
Maps the given x and y coordinates to the transformed position using the provided transform.
Args:
x (float): The x-coordinate to transform.
y (float): The y-coordinate to transform.
transform (QTransform): The transformation to apply.
"""
origin = transform.map(QPointF(0, 0))
row = transform.map(QPointF(0, y)) - origin
col = transform.map(QPointF(x, 0)) - origin
return row, col
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
@@ -601,18 +512,8 @@ class Crosshair(QObject):
image = item.image
if image is None:
continue
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
pt = inv_transform.map(QPointF(x, y))
px, py = pt.x(), pt.y()
else:
px, py = x, y
# Clip to valid pixel indices
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}"
break
@@ -632,19 +533,15 @@ class Crosshair(QObject):
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
@SafeSlot()
def reset(self):
"""Resets the crosshair to its initial state."""
def cleanup(self):
if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.clear_markers()
def cleanup(self):
self.reset()
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()

View File

@@ -28,10 +28,6 @@ class EntryValidator:
if not available_entries:
available_entries = [name]
# edge case for if name is passed instead of full_name, should not happen
if entry in signals_dict:
entry = signals_dict[entry].get("obj_name", entry)
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in available_entries:

View File

@@ -171,9 +171,8 @@ class TypedForm(BECWidget, QWidget):
class PydanticModelForm(TypedForm):
form_data_updated = Signal(dict)
form_data_cleared = Signal(NoneType)
validity_proc = Signal(bool)
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(
self,
@@ -205,7 +204,7 @@ class PydanticModelForm(TypedForm):
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Validity" # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
@@ -265,18 +264,16 @@ class PydanticModelForm(TypedForm):
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on form_data_cleared and returns false."""
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.form_data_updated.emit(metadata_dict)
self.validity_proc.emit(True)
self.metadata_updated.emit(metadata_dict)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
self.metadata_cleared.emit(None)
return False

View File

@@ -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
@@ -360,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):
@@ -402,29 +390,17 @@ class ListFormItem(DynamicFormItem):
def _add_buttons(self):
self._button_holder = QWidget()
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._buttons = QVBoxLayout()
self._buttons.setContentsMargins(0, 0, 0, 0)
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_remove_button_holder = QWidget()
self._add_remove_button_layout = QHBoxLayout()
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
self._add_button = QPushButton("+")
self._add_button.setMinimumHeight(15)
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setMinimumHeight(15)
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_remove_button_holder)
self._add_remove_button_layout.addWidget(self._add_button)
self._add_remove_button_layout.addWidget(self._remove_button)
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
def _set_pretty_display(self):
super()._set_pretty_display()

View File

@@ -7,7 +7,7 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)

View File

@@ -2,7 +2,6 @@
# 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
{widget_import}
@@ -21,8 +20,6 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = {plugin_name_pascal}(parent)
return t

View File

@@ -37,7 +37,6 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
"""
modules = _get_available_plugins("bec.widgets.user_widgets")
loaded_plugins = {}
print(modules)
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_plugins)
for name, mod_cls in mods:
@@ -201,7 +200,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
if issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)

View File

@@ -1,694 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFontDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class PropertyEditor(QWidget):
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
super().__init__(parent)
self._target = target
self._bec_only = show_only_bec
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Name row
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(target.objectName())
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# BEC only checkbox
filter_row = QHBoxLayout()
self.chk_show_qt = QCheckBox("Show Qt properties")
self.chk_show_qt.setChecked(False)
filter_row.addWidget(self.chk_show_qt)
filter_row.addStretch(1)
layout.addLayout(filter_row)
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
# Main tree widget
self.tree = QTreeWidget(self)
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Property", "Value"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(False)
layout.addWidget(self.tree)
self._build()
def _class_chain(self):
chain = []
mo = self._target.metaObject()
while mo is not None:
chain.append(mo)
mo = mo.superClass()
return chain
def set_show_only_bec(self, flag: bool):
self._bec_only = flag
self._build()
def _set_equal_columns(self):
header = self.tree.header()
header.setSectionResizeMode(0, QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Interactive)
w = self.tree.viewport().width() or self.tree.width()
if w > 0:
half = max(1, w // 2)
self.tree.setColumnWidth(0, half)
self.tree.setColumnWidth(1, w - half)
def _build(self):
self.tree.clear()
for mo in self._class_chain():
class_name = mo.className()
if self._bec_only and not self._is_bec_metaobject(mo):
continue
group_item = QTreeWidgetItem(self.tree, [class_name])
group_item.setFirstColumnSpanned(True)
start = mo.propertyOffset()
end = mo.propertyCount()
for i in range(start, end):
prop = mo.property(i)
if (
not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
name = prop.name()
if name == "objectName":
continue
value = self._target.property(name)
self._add_property_row(group_item, name, value, prop)
if group_item.childCount() == 0:
idx = self.tree.indexOfTopLevelItem(group_item)
self.tree.takeTopLevelItem(idx)
self.tree.expandAll()
QTimer.singleShot(0, self._set_equal_columns)
def _enum_int(self, obj) -> int:
return int(getattr(obj, "value", obj))
def _make_sizepolicy_editor(self, name: str, sp):
if not isinstance(sp, QSizePolicy):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
h_combo = QComboBox(wrap)
v_combo = QComboBox(wrap)
hs = QSpinBox(wrap)
vs = QSpinBox(wrap)
for b in (hs, vs):
b.setRange(0, 16777215)
policies = [
(QSizePolicy.Fixed, "Fixed"),
(QSizePolicy.Minimum, "Minimum"),
(QSizePolicy.Maximum, "Maximum"),
(QSizePolicy.Preferred, "Preferred"),
(QSizePolicy.Expanding, "Expanding"),
(QSizePolicy.MinimumExpanding, "MinExpanding"),
(QSizePolicy.Ignored, "Ignored"),
]
for pol, text in policies:
h_combo.addItem(text, self._enum_int(pol))
v_combo.addItem(text, self._enum_int(pol))
def _set_current(combo, val):
idx = combo.findData(self._enum_int(val))
if idx >= 0:
combo.setCurrentIndex(idx)
_set_current(h_combo, sp.horizontalPolicy())
_set_current(v_combo, sp.verticalPolicy())
hs.setValue(sp.horizontalStretch())
vs.setValue(sp.verticalStretch())
def apply_changes():
hp = QSizePolicy.Policy(h_combo.currentData())
vp = QSizePolicy.Policy(v_combo.currentData())
nsp = QSizePolicy(hp, vp)
nsp.setHorizontalStretch(hs.value())
nsp.setVerticalStretch(vs.value())
self._target.setProperty(name, nsp)
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
hs.valueChanged.connect(lambda _=None: apply_changes())
vs.valueChanged.connect(lambda _=None: apply_changes())
row.addWidget(h_combo)
row.addWidget(v_combo)
row.addWidget(hs)
row.addWidget(vs)
return wrap
def _make_locale_editor(self, name: str, loc):
if not isinstance(loc, QLocale):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
lang_combo = QComboBox(wrap)
country_combo = QComboBox(wrap)
for lang in QLocale.Language:
try:
lang_int = self._enum_int(lang)
except Exception:
continue
if lang_int < 0:
continue
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
lang_combo.addItem(name_txt, lang_int)
def populate_countries():
country_combo.blockSignals(True)
country_combo.clear()
for terr in QLocale.Country:
try:
terr_int = self._enum_int(terr)
except Exception:
continue
if terr_int < 0:
continue
text = QLocale.countryToString(QLocale.Country(terr_int))
country_combo.addItem(text, terr_int)
cur_country = self._enum_int(loc.country())
idx = country_combo.findData(cur_country)
if idx >= 0:
country_combo.setCurrentIndex(idx)
country_combo.blockSignals(False)
cur_lang = self._enum_int(loc.language())
idx = lang_combo.findData(cur_lang)
if idx >= 0:
lang_combo.setCurrentIndex(idx)
populate_countries()
def apply_locale():
lang = QLocale.Language(int(lang_combo.currentData()))
country = QLocale.Country(int(country_combo.currentData()))
self._target.setProperty(name, QLocale(lang, country))
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
row.addWidget(lang_combo)
row.addWidget(country_combo)
return wrap
def _make_icon_editor(self, name: str, icon):
btn = QPushButton(self)
btn.setText("Choose…")
if isinstance(icon, QIcon) and not icon.isNull():
btn.setIcon(icon)
def pick():
path, _ = QFileDialog.getOpenFileName(
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
)
if path:
ic = QIcon(path)
self._target.setProperty(name, ic)
btn.setIcon(ic)
btn.clicked.connect(pick)
return btn
def _spin_pair(self, ints: bool = True):
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
if ints:
box1.setRange(-10_000_000, 10_000_000)
box2.setRange(-10_000_000, 10_000_000)
else:
for b in (box1, box2):
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
row.addWidget(box1)
row.addWidget(box2)
return wrap, box1, box2
def _spin_quad(self, ints: bool = True):
s = QSpinBox if ints else QDoubleSpinBox
boxes = [s(self) for _ in range(4)]
if ints:
for b in boxes:
b.setRange(-10_000_000, 10_000_000)
else:
for b in boxes:
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
for b in boxes:
row.addWidget(b)
return wrap, boxes
def _make_font_editor(self, name: str, value):
btn = QPushButton(self)
if isinstance(value, QFont):
btn.setText(f"{value.family()}, {value.pointSize()}pt")
else:
btn.setText("Select font…")
def pick():
ok, font = QFontDialog.getFont(
value if isinstance(value, QFont) else QFont(), self, "Select Font"
)
if ok:
self._target.setProperty(name, font)
btn.setText(f"{font.family()}, {font.pointSize()}pt")
btn.clicked.connect(pick)
return btn
def _make_color_editor(self, initial: QColor, apply_cb):
btn = QPushButton(self)
if isinstance(initial, QColor):
btn.setText(initial.name())
btn.setStyleSheet(f"background:{initial.name()};")
else:
btn.setText("Select color…")
def pick():
col = QColorDialog.getColor(
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
)
if col.isValid():
apply_cb(col)
btn.setText(col.name())
btn.setStyleSheet(f"background:{col.name()};")
btn.clicked.connect(pick)
return btn
def _apply_palette_color(
self,
name: str,
pal: QPalette,
group: QPalette.ColorGroup,
role: QPalette.ColorRole,
col: QColor,
):
pal.setColor(group, role, col)
self._target.setProperty(name, pal)
def _make_palette_editor(self, name: str, pal: QPalette):
if not isinstance(pal, QPalette):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
group_combo = QComboBox(wrap)
role_combo = QComboBox(wrap)
pick_btn = self._make_color_editor(
pal.color(QPalette.Active, QPalette.WindowText),
lambda col: self._apply_palette_color(
name, pal, QPalette.Active, QPalette.WindowText, col
),
)
groups = [
(QPalette.Active, "Active"),
(QPalette.Inactive, "Inactive"),
(QPalette.Disabled, "Disabled"),
]
for g, label in groups:
group_combo.addItem(label, int(getattr(g, "value", g)))
roles = [
(QPalette.WindowText, "WindowText"),
(QPalette.Window, "Window"),
(QPalette.Base, "Base"),
(QPalette.AlternateBase, "AlternateBase"),
(QPalette.ToolTipBase, "ToolTipBase"),
(QPalette.ToolTipText, "ToolTipText"),
(QPalette.Text, "Text"),
(QPalette.Button, "Button"),
(QPalette.ButtonText, "ButtonText"),
(QPalette.BrightText, "BrightText"),
(QPalette.Highlight, "Highlight"),
(QPalette.HighlightedText, "HighlightedText"),
]
for r, label in roles:
role_combo.addItem(label, int(getattr(r, "value", r)))
def rewire_button():
g = QPalette.ColorGroup(int(group_combo.currentData()))
r = QPalette.ColorRole(int(role_combo.currentData()))
col = pal.color(g, r)
while row.count() > 2:
w = row.takeAt(2).widget()
if w:
w.deleteLater()
btn = self._make_color_editor(
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
)
row.addWidget(btn)
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
row.addWidget(group_combo)
row.addWidget(role_combo)
row.addWidget(pick_btn)
return wrap
def _make_cursor_editor(self, name: str, value):
combo = QComboBox(self)
shapes = [
(Qt.ArrowCursor, "Arrow"),
(Qt.IBeamCursor, "IBeam"),
(Qt.WaitCursor, "Wait"),
(Qt.CrossCursor, "Cross"),
(Qt.UpArrowCursor, "UpArrow"),
(Qt.SizeAllCursor, "SizeAll"),
(Qt.PointingHandCursor, "PointingHand"),
(Qt.ForbiddenCursor, "Forbidden"),
(Qt.WhatsThisCursor, "WhatsThis"),
(Qt.BusyCursor, "Busy"),
]
current_shape = None
if isinstance(value, QCursor):
try:
enum_val = value.shape()
current_shape = int(getattr(enum_val, "value", enum_val))
except Exception:
current_shape = None
for shape, text in shapes:
combo.addItem(text, int(getattr(shape, "value", shape)))
if current_shape is not None:
idx = combo.findData(current_shape)
if idx >= 0:
combo.setCurrentIndex(idx)
def apply_index(i):
shape_val = int(combo.itemData(i))
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
combo.currentIndexChanged.connect(apply_index)
return combo
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
item = QTreeWidgetItem(parent, [name, ""])
editor = self._make_editor(name, value, prop)
if editor is not None:
self.tree.setItemWidget(item, 1, editor)
else:
item.setText(1, repr(value))
def _is_bec_metaobject(self, mo) -> bool:
cname = mo.className()
for cls in type(self._target).mro():
if getattr(cls, "__name__", None) == cname:
mod = getattr(cls, "__module__", "")
return mod.startswith("bec_widgets")
return False
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
if not meta_enum.isFlag():
key = meta_enum.valueToKey(value_int)
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
parts = []
for i in range(meta_enum.keyCount()):
k = meta_enum.key(i)
v = meta_enum.value(i)
if value_int & v:
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
parts.append(k)
return " | ".join(parts) if parts else "0"
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
try:
return int(value)
except Exception:
pass
v = getattr(value, "value", None)
if isinstance(v, (int,)):
return int(v)
n = getattr(value, "name", None)
if isinstance(n, str):
res = meta_enum.keyToValue(n)
if res != -1:
return int(res)
s = str(value)
parts = [p.strip() for p in s.replace(",", "|").split("|")]
keys = []
for p in parts:
if "." in p:
p = p.split(".")[-1]
keys.append(p)
keystr = "|".join(keys)
try:
res = meta_enum.keysToValue(keystr)
if res != -1:
return int(res)
except Exception:
pass
return 0
def _make_enum_editor(self, name: str, value, prop):
meta_enum = prop.enumerator()
current = self._enum_value_to_int(meta_enum, value)
if not meta_enum.isFlag():
combo = QComboBox(self)
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
combo.addItem(key, meta_enum.value(i))
idx = combo.findData(current)
if idx < 0:
txt = self._enum_text(meta_enum, current)
idx = combo.findText(txt)
combo.setCurrentIndex(max(idx, 0))
def apply_index(i):
v = combo.itemData(i)
self._target.setProperty(name, int(v))
combo.currentIndexChanged.connect(apply_index)
return combo
btn = QToolButton(self)
btn.setText(self._enum_text(meta_enum, current))
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
actions = []
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
act = menu.addAction(key)
act.setCheckable(True)
act.setChecked(bool(current & meta_enum.value(i)))
actions.append(act)
btn.setMenu(menu)
def apply_flags():
flags = 0
for i, act in enumerate(actions):
if act.isChecked():
flags |= meta_enum.value(i)
self._target.setProperty(name, int(flags))
btn.setText(self._enum_text(meta_enum, flags))
menu.triggered.connect(lambda _a: apply_flags())
return btn
def _make_editor(self, name: str, value, prop):
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
if prop.isEnumType():
return self._make_enum_editor(name, value, prop)
if isinstance(value, QColor):
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
if isinstance(value, QFont):
return self._make_font_editor(name, value)
if isinstance(value, QPalette):
return self._make_palette_editor(name, value)
if isinstance(value, QCursor):
return self._make_cursor_editor(name, value)
if isinstance(value, QSizePolicy):
ed = self._make_sizepolicy_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QLocale):
ed = self._make_locale_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QIcon):
ed = self._make_icon_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QSize):
wrap, w, h = self._spin_pair(ints=True)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
return wrap
if isinstance(value, QSizeF):
wrap, w, h = self._spin_pair(ints=False)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
return wrap
if isinstance(value, QPoint):
wrap, x, y = self._spin_pair(ints=True)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
return wrap
if isinstance(value, QPointF):
wrap, x, y = self._spin_pair(ints=False)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
return wrap
if isinstance(value, QRect):
wrap, boxes = self._spin_quad(ints=True)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rect():
self._target.setProperty(
name,
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rect())
return wrap
if isinstance(value, QRectF):
wrap, boxes = self._spin_quad(ints=False)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rectf():
self._target.setProperty(
name,
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rectf())
return wrap
if isinstance(value, bool):
w = QCheckBox(self)
w.setChecked(bool(value))
w.toggled.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, int) and not isinstance(value, bool):
w = QSpinBox(self)
w.setRange(-10_000_000, 10_000_000)
w.setValue(int(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, float):
w = QDoubleSpinBox(self)
w.setDecimals(6)
w.setRange(-1e12, 1e12)
w.setSingleStep(0.1)
w.setValue(float(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, str):
w = QLineEdit(self)
w.setText(value)
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
return w
return None
class DemoApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
layout = QHBoxLayout(self)
# Create a BECWidget instance example
waveform = self.create_waveform()
# property editor for the BECWidget
property_editor = PropertyEditor(waveform, show_only_bec=True)
layout.addWidget(waveform)
layout.addWidget(property_editor)
def create_waveform(self):
"""Create a new waveform widget."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
waveform = Waveform(parent=self)
waveform.title = "New Waveform"
waveform.x_label = "X Axis"
waveform.y_label = "Y Axis"
return waveform
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
demo = DemoApp()
demo.setWindowTitle("Property Editor Demo")
demo.resize(1200, 800)
demo.show()
sys.exit(app.exec())

View File

@@ -13,17 +13,3 @@ def register_rpc_methods(cls):
if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name)
return cls
def rpc_timeout(timeout: float | None):
"""
Decorator to set a timeout for RPC methods.
The actual implementation of timeout handling is within the cli module. This decorator
is solely to inform the generate-cli command about the timeout value.
"""
def decorator(func):
func.__rpc_timeout__ = timeout # Store the timeout value in the function
return func
return decorator

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -230,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:

View File

@@ -1,25 +1,44 @@
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)
if not module_is_registered("bec_widgets.utils.serialization"):
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
def module_is_registered(module_name: str) -> bool:
"""
Check if the module is registered in the encoder.
Args:
module_name (str): The name of the module to check.
Returns:
bool: True if the module is registered, False otherwise.
"""
# pylint: disable=protected-access
for enc in msgpack._encoder:
if enc[0].__module__ == module_name:
return True
return False
def encode_qpointf(obj):
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
def decode_qpointf(obj):
"""
no-op function since QPointF is encoded as a list of floats.
"""
return obj

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -43,15 +42,11 @@ class PerformanceConnection(BundleConnection):
super().__init__()
self._connected = False
@SafeSlot(bool)
def set_fps_monitor(self, enabled: bool):
setattr(self.target_widget, "enable_fps_monitor", enabled)
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
self.set_fps_monitor
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
)
def disconnect(self):
@@ -59,6 +54,5 @@ class PerformanceConnection(BundleConnection):
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
self.set_fps_monitor
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
)
self._connected = False

View File

@@ -264,48 +264,6 @@ class WidgetIO:
return WidgetIO._handlers[base]
return None
@staticmethod
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
"""
Return widgets matching the given class (or class-name string).
Args:
widget_class: Either a QWidget subclass or its class-name as a string.
recursive: If True (default), traverse all top-level widgets and their children;
if False, scan app.allWidgets() for a flat list.
Returns:
List of QWidget instances matching the class or class-name.
"""
app = QApplication.instance()
if app is None:
raise RuntimeError("No QApplication instance found.")
# Match by class-name string
if isinstance(widget_class, str):
name = widget_class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if top.__class__.__name__ == name:
result.append(top)
result.extend(
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
)
return result
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
# Match by actual class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if isinstance(top, widget_class):
result.append(top)
result.extend(top.findChildren(widget_class))
return result
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
################## for exporting and importing widget hierarchies ##################

View File

@@ -27,7 +27,6 @@ class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
PLUGIN = False
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"

View File

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

View File

@@ -389,7 +389,6 @@ class BECDock(BECWidget, Dock):
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
widget.deleteLater()
def delete_all(self):
"""

View File

@@ -28,7 +28,6 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_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
@@ -37,7 +36,6 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.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.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -72,7 +70,6 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock",
"attach_all",
"save_state",
"screenshot",
"restore_state",
]
@@ -157,9 +154,6 @@ class BECDockArea(BECWidget, QWidget):
filled=True,
parent=self,
),
"heatmap": MaterialIconAction(
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
),
},
),
)
@@ -186,12 +180,6 @@ class BECDockArea(BECWidget, QWidget):
filled=True,
parent=self,
),
"device_browser": MaterialIconAction(
icon_name=DeviceBrowser.ICON_NAME,
tooltip="Add Device Browser",
filled=True,
parent=self,
),
},
),
)
@@ -275,16 +263,11 @@ class BECDockArea(BECWidget, QWidget):
"restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
bundle.add_action("screenshot")
self.toolbar.add_bundle(bundle)
def _hook_toolbar(self):
@@ -308,9 +291,6 @@ class BECDockArea(BECWidget, QWidget):
menu_plots.actions["motor_map"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
menu_plots.actions["heatmap"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
)
# Menu Devices
menu_devices.actions["scan_control"].action.triggered.connect(
@@ -319,9 +299,6 @@ class BECDockArea(BECWidget, QWidget):
menu_devices.actions["positioner_box"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
menu_devices.actions["device_browser"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="DeviceBrowser")
)
# Menu Utils
menu_utils.actions["queue"].action.triggered.connect(
@@ -349,7 +326,6 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state
)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:

View File

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

View File

@@ -1,19 +1,22 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.dock import BECDockArea
DOM_XML = """
<ui language='c++'>
<widget class='BECDockArea' name='bec_dock_area'>
<widget class='BECDockArea' name='dock_area'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = BECDockArea(parent)
return t
@@ -30,13 +31,13 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Containers"
return "BEC Plots"
def icon(self):
return designer_material_icon(BECDockArea.ICON_NAME)
def includeFile(self):
return "bec_dock_area"
return "dock_area"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -51,7 +52,7 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "BECDockArea"
def toolTip(self):
return ""
return "BECDockArea"
def whatsThis(self):
return self.toolTip()

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin
from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import QSize
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
DOM_XML = """
<ui language='c++'>
<widget class='BECMainWindow' name='bec_main_window'>
</widget>
</ui>
"""
class BECMainWindowPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
# We want to initialize BECMainWindow upon starting designer
t = BECMainWindow(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Containers"
def icon(self):
return designer_material_icon(BECMainWindow.ICON_NAME)
def includeFile(self):
return "bec_main_window"
def initialize(self, form_editor):
import os
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
import bec_widgets
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True)
app = QApplication.instance()
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
self._form_editor = form_editor
def isContainer(self):
return True
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECMainWindow"
def toolTip(self):
return "BECMainWindow"
def whatsThis(self):
return self.toolTip()

View File

@@ -19,28 +19,20 @@ 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
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker,
NotificationCentre,
NotificationIndicator,
)
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
# Ensure the application does not use the native menu bar on macOS to be consistent with linux development.
QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True)
class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
RPC = False
PLUGIN = False
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
@@ -58,14 +50,6 @@ class BECMainWindow(BECWidget, QMainWindow):
self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title)
# Notification Centre overlay
self.notification_centre = NotificationCentre(parent=self) # Notification layer
self.notification_broker = BECNotificationBroker()
self._nc_margin = 16
self._position_notification_centre()
# Init ui
self._init_ui()
self._connect_to_theme_change()
@@ -74,34 +58,6 @@ class BECMainWindow(BECWidget, QMainWindow):
self.display_client_message, MessageEndpoints.client_info()
)
def setCentralWidget(self, widget: QWidget, qt_default: bool = False): # type: ignore[override]
"""
Reimplement QMainWindow.setCentralWidget so that the *main content*
widget always lives on the lower layer of the stacked layout that
hosts our notification overlays.
Args:
widget: The widget that should become the new central content.
qt_default: When *True* the call is forwarded to the base class so
that Qt behaves exactly as the original implementation (used
during __init__ when we first install ``self._full_content``).
"""
super().setCentralWidget(widget)
self.notification_centre.raise_()
self.statusBar().raise_()
def resizeEvent(self, event):
super().resizeEvent(event)
self._position_notification_centre()
def _position_notification_centre(self):
"""Keep the notification panel at a fixed margin top-right."""
if not hasattr(self, "notification_centre"):
return
margin = getattr(self, "_nc_margin", 16) # px
nc = self.notification_centre
nc.move(self.width() - nc.width() - margin, margin)
################################################################################
# MainWindow Elements Initialization
################################################################################
@@ -138,26 +94,6 @@ class BECMainWindow(BECWidget, QMainWindow):
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
# Setup NotificationIndicator to bottom right of the status bar
self._add_notification_indicator()
################################################################################
# Notification indicator and Notification Centre helpers
def _add_notification_indicator(self):
"""
Add the notification indicator to the status bar and hook the signals.
"""
# Add the notification indicator to the status bar
self.notification_indicator = NotificationIndicator(self)
self.status_bar.addPermanentWidget(self.notification_indicator)
# Connect the notification broker to the indicator
self.notification_centre.counts_updated.connect(self.notification_indicator.update_counts)
self.notification_indicator.filter_changed.connect(self.notification_centre.apply_filter)
self.notification_indicator.show_all_requested.connect(self.notification_centre.show_all)
self.notification_indicator.hide_all_requested.connect(self.notification_centre.hide_all)
################################################################################
# Client message status bar widget helpers
@@ -443,12 +379,12 @@ class BECMainWindow(BECWidget, QMainWindow):
@SafeSlot(str)
def change_theme(self, theme: str):
"""
Change the theme of the application and propagate it to widgets.
Change the theme of the application.
Args:
theme(str): Either "light" or "dark".
theme(str): The theme to apply, either "light" or "dark".
"""
set_theme(theme) # emits theme_updated and applies palette globally
apply_theme(theme)
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
@@ -494,16 +430,15 @@ class BECMainWindow(BECWidget, QMainWindow):
super().cleanup()
class BECMainWindowNoRPC(BECMainWindow):
RPC = False
PLUGIN = False
class UILaunchWindow(BECMainWindow):
RPC = True
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = BECMainWindow()
main_window = UILaunchWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -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.containers.main_window.bec_main_window_plugin import (
BECMainWindowPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMainWindowPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -2,7 +2,6 @@
# 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.control.buttons.button_abort.button_abort import AbortButton
@@ -21,8 +20,6 @@ class AbortButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = AbortButton(parent)
return t

View File

@@ -2,7 +2,6 @@
# 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.control.buttons.button_reset.button_reset import ResetButton
@@ -21,8 +20,6 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResetButton(parent)
return t
@@ -51,7 +48,7 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ResetButton"
def toolTip(self):
return "A button that resets the scan queue."
return "A button that reset the scan queue."
def whatsThis(self):
return self.toolTip()

View File

@@ -2,7 +2,6 @@
# 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.control.buttons.button_resume.button_resume import ResumeButton
@@ -21,8 +20,6 @@ class ResumeButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ResumeButton(parent)
return t

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
@@ -14,6 +15,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = StopButton(parent)
return t

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
@@ -16,6 +17,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -23,8 +26,6 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionIndicator(parent)
return t
@@ -53,7 +54,7 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
return "PositionIndicator"
def toolTip(self):
return ""
return "PositionIndicator"
def whatsThis(self):
return self.toolTip()

View File

@@ -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),
@@ -138,11 +138,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
signals = msg_content.get("signals", {})
# pylint: disable=protected-access
hinted_signals = self.dev[device]._hints
precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
precision = self.dev[device].precision
spinner = ui_components["spinner"]
position_indicator = ui_components["position_indicator"]
@@ -182,13 +178,11 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
spinner.setVisible(False)
if readback_val is not None:
text = f"{readback_val:.{precision}f}"
readback.setText(text)
readback.setText(f"{readback_val:.{precision}f}")
position_emit(readback_val)
if setpoint_val is not None:
text = f"{setpoint_val:.{precision}f}"
setpoint.setText(text)
setpoint.setText(f"{setpoint_val:.{precision}f}")
limits = self.dev[device].limits
limit_update(limits)
@@ -211,13 +205,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
ui["readback"].setToolTip(f"{device} readback")
ui["setpoint"].setToolTip(f"{device} setpoint")
ui["step_size"].setToolTip(f"Step size for {device}")
precision = getattr(self.dev[device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
precision = self.dev[device].precision
if precision is not None:
ui["step_size"].setDecimals(precision)
ui["step_size"].setValue(10**-precision * 10)
def _swap_readback_signal_connection(self, slot, old_device, new_device):
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))

View File

@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner", "screenshot"]
USER_ACCESS = ["set_positioner"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)

View File

@@ -1,13 +1,12 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
PositionerBox,
)
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
DOM_XML = """
<ui language='c++'>
@@ -15,6 +14,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,8 +23,6 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerBox(parent)
return t
@@ -32,7 +30,7 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox.ICON_NAME)

View File

@@ -2,7 +2,6 @@
# 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.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d import (
@@ -23,8 +22,6 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerBox2D(parent)
return t
@@ -32,7 +29,7 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerBox2D.ICON_NAME)

View File

@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str)

View File

@@ -1,13 +1,12 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
PositionerControlLine,
)
from bec_widgets.widgets.control.device_control.positioner_box import PositionerControlLine
DOM_XML = """
<ui language='c++'>
@@ -15,6 +14,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,8 +23,6 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerControlLine(parent)
return t
@@ -32,7 +30,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerControlLine.ICON_NAME)
@@ -53,7 +51,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
return "PositionerControlLine"
def toolTip(self):
return "A widget that controls a single device."
return "A widget that controls a single positioner in line form."
def whatsThis(self):
return self.toolTip()

View File

@@ -15,7 +15,7 @@ logger = bec_logger.logger
class PositionerGroupBox(QGroupBox):
PLUGIN = True
position_update = Signal(float)
def __init__(self, parent, dev_name):
@@ -45,12 +45,7 @@ class PositionerGroupBox(QGroupBox):
def _on_position_update(self, pos: float):
self.position_update.emit(pos)
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
try:
precision = int(precision)
except (TypeError, ValueError):
precision = int(8)
self.widget.label = f"{pos:.{precision}f}"
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % pos
def close(self):
self.widget.close()
@@ -60,7 +55,6 @@ class PositionerGroupBox(QGroupBox):
class PositionerGroup(BECWidget, QWidget):
"""Simple Widget to control a positioner in box form"""
PLUGIN = True
ICON_NAME = "grid_view"
USER_ACCESS = ["set_positioners"]

View File

@@ -1 +1 @@
{'files': ['positioner_group.py']}
{'files': ['positioner_group.py']}

View File

@@ -1,8 +1,9 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
@@ -15,6 +16,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -23,8 +25,6 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = PositionerGroup(parent)
return t
@@ -32,7 +32,7 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(PositionerGroup.ICON_NAME)
@@ -53,7 +53,7 @@ class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "PositionerGroup"
def toolTip(self):
return "Simple Widget to control a positioner in box form"
return "Container Widget to control positioners in compact form, in a grid"
def whatsThis(self):
return self.toolTip()

View File

@@ -112,9 +112,7 @@ class DeviceInputBase(BECWidget):
WidgetIO.set_value(widget=self, value=device)
self.config.default = device
else:
logger.warning(
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
)
logger.warning(f"Device {device} is not in the filtered selection.")
@SafeSlot()
def update_devices_from_filters(self):
@@ -133,8 +131,7 @@ class DeviceInputBase(BECWidget):
# Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs]
if current_device != "":
self.set_device(current_device)
self.set_device(current_device)
@SafeSlot(list)
def set_available_devices(self, devices: list[str]):

View File

@@ -69,7 +69,7 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal (str): signal name.
"""
if self.validate_signal(signal):
if self.validate_signal(signal) is True:
WidgetIO.set_value(widget=self, value=signal)
self.config.default = signal
else:

View File

@@ -1 +1,3 @@
{'files': ['device_combobox.py']}
{
"files": ["device_combobox.py"]
}

View File

@@ -1,19 +1,22 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
DOM_XML = """
<ui language='c++'>
<widget class='DeviceComboBox' name='device_combo_box'>
<widget class='DeviceComboBox' name='device_combobox'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceComboBox(parent)
return t
@@ -36,7 +37,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return designer_material_icon(DeviceComboBox.ICON_NAME)
def includeFile(self):
return "device_combo_box"
return "device_combobox"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -51,7 +52,7 @@ class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "DeviceComboBox"
def toolTip(self):
return ""
return "Device ComboBox Example for BEC Widgets"
def whatsThis(self):
return self.toolTip()

View File

@@ -5,7 +5,6 @@ from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
@@ -62,7 +61,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
self._set_first_element_as_empty = False
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
@@ -95,31 +93,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def on_device_update(self, action: str, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.

View File

@@ -1 +1,3 @@
{'files': ['device_line_edit.py']}
{
"files": ["device_line_edit.py"]
}

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
@@ -16,6 +17,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -23,8 +26,6 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DeviceLineEdit(parent)
return t
@@ -53,7 +54,7 @@ class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "DeviceLineEdit"
def toolTip(self):
return ""
return "Device LineEdit Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -2,7 +2,6 @@
# 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.control.device_input.signal_combobox.signal_combobox import SignalComboBox
@@ -21,8 +20,6 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = SignalComboBox(parent)
return t
@@ -51,7 +48,7 @@ class SignalComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalComboBox"
def toolTip(self):
return ""
return "Signal ComboBox Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,10 +1,8 @@
from __future__ import annotations
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
@@ -56,7 +54,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self._set_first_element_as_empty = True
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
@@ -93,31 +90,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
@@ -170,10 +142,6 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return
self.device_signal_changed.emit(text)
@property
def selected_signal_comp_name(self) -> str:
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

@@ -2,7 +2,6 @@
# 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.control.device_input.signal_line_edit.signal_line_edit import (
@@ -23,8 +22,6 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = SignalLineEdit(parent)
return t
@@ -53,7 +50,7 @@ class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "SignalLineEdit"
def toolTip(self):
return ""
return "Signal LineEdit Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

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

View File

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

View File

@@ -45,7 +45,6 @@ class ScanControl(BECWidget, QWidget):
Widget to submit new scans to the queue.
"""
USER_ACCESS = ["remove", "screenshot"]
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
@@ -170,8 +169,8 @@ class ScanControl(BECWidget, QWidget):
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form()
def populate_scans(self):

View File

@@ -2,7 +2,6 @@
# 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.control.scan_control.scan_control import ScanControl
@@ -21,8 +20,6 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ScanControl(parent)
return t
@@ -30,7 +27,7 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Device Control"
return "Device Control"
def icon(self):
return designer_material_icon(ScanControl.ICON_NAME)
@@ -51,7 +48,7 @@ class ScanControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ScanControl"
def toolTip(self):
return ""
return "ScanControl"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,4 +1,4 @@
from typing import Literal, Sequence
from typing import Literal
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
@@ -36,7 +36,7 @@ class ScanArgType:
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS_DICT = "dict" # Used when the type is provided as a dict with Literal key
LITERALS = "dict"
class SettingsDialog(QDialog):
@@ -83,39 +83,6 @@ class ScanSpinBox(QSpinBox):
self.setValue(default)
class ScanLiteralsComboBox(QComboBox):
def __init__(
self, parent=None, arg_name: str | None = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.default = default
if default is not None:
self.setCurrentText(default)
def set_literals(self, literals: Sequence[str | int | float | None]) -> None:
"""
Set the list of literals for the combo box.
Args:
literals: List of literal values (can be strings, integers, floats or None)
"""
self.clear()
literals = set(literals) # Remove duplicates
if None in literals:
literals.remove(None)
self.addItem("")
self.addItems([str(value) for value in literals])
# find index of the default value
index = max(self.findText(str(self.default)), 0)
self.setCurrentIndex(index)
def get_value(self) -> str | None:
return self.currentText() if self.currentText() else None
class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
@@ -170,7 +137,7 @@ class ScanGroupBox(QGroupBox):
ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS_DICT: ScanLiteralsComboBox,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
}
device_selected = Signal(str)
@@ -259,11 +226,7 @@ class ScanGroupBox(QGroupBox):
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
default = item.get("default", None)
item_type = item.get("type", None)
if isinstance(item_type, dict) and "Literal" in item_type:
widget_class = self.WIDGET_HANDLER.get(ScanArgType.LITERALS_DICT, None)
else:
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
if widget_class is None:
logger.error(
f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'"
@@ -276,8 +239,6 @@ class ScanGroupBox(QGroupBox):
widget.set_device_filter(BECDeviceFilter.DEVICE)
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
tooltip = item.get("tooltip", None)
if tooltip is not None:
widget.setToolTip(item["tooltip"])
@@ -375,8 +336,6 @@ class ScanGroupBox(QGroupBox):
widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit) and device_object:
value = widget.get_current_device().name
elif isinstance(widget, ScanLiteralsComboBox):
value = widget.get_value()
else:
value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value

View File

@@ -2,7 +2,6 @@
# 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.dap.dap_combo_box.dap_combo_box import DapComboBox
@@ -21,8 +20,6 @@ class DapComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = DapComboBox(parent)
return t

View File

@@ -2,7 +2,6 @@
# 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.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
@@ -21,8 +20,6 @@ class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = LMFitDialog(parent)
return t
@@ -51,7 +48,7 @@ class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LMFitDialog"
def toolTip(self):
return "Dialog for displaying the fit summary and params for LMFit DAP processes"
return "LMFitDialog"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,244 +0,0 @@
from typing import Literal
import qtmonaco
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_name
class MonacoWidget(BECWidget, QWidget):
"""
A simple Monaco editor widget
"""
text_changed = Signal(str)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
"set_text",
"get_text",
"insert_text",
"delete_line",
"set_language",
"get_language",
"set_theme",
"get_theme",
"set_readonly",
"set_cursor",
"current_cursor",
"set_minimap_enabled",
"set_vim_mode_enabled",
"set_lsp_header",
"get_lsp_header",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.editor = qtmonaco.Monaco(self)
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.initialized.connect(self.apply_theme)
def apply_theme(self, theme: str | None = None) -> None:
"""
Apply the current theme to the Monaco editor.
Args:
theme (str, optional): The theme to apply. If None, the current theme will be used.
"""
if theme is None:
theme = get_theme_name()
editor_theme = "vs" if theme == "light" else "vs-dark"
self.set_theme(editor_theme)
def set_text(self, text: str) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
"""
self.editor.set_text(text)
def get_text(self) -> str:
"""
Get the current text from the Monaco editor.
"""
return self.editor.get_text()
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
self.editor.insert_text(text, line, column)
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
self.editor.delete_line(line)
def set_cursor(
self,
line: int,
column: int = 1,
move_to_position: Literal[None, "center", "top", "position"] = None,
) -> None:
"""
Set the cursor position in the Monaco editor.
Args:
line (int): Line number (1-based).
column (int): Column number (1-based), defaults to 1.
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
"""
self.editor.set_cursor(line, column, move_to_position)
def current_cursor(self) -> dict[str, int]:
"""
Get the current cursor position in the Monaco editor.
Returns:
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
"""
return self.editor.current_cursor
def set_language(self, language: str) -> None:
"""
Set the programming language for syntax highlighting in the Monaco editor.
Args:
language (str): The programming language to set (e.g., "python", "javascript").
"""
self.editor.set_language(language)
def get_language(self) -> str:
"""
Get the current programming language set in the Monaco editor.
"""
return self.editor.get_language()
def set_readonly(self, read_only: bool) -> None:
"""
Set the Monaco editor to read-only mode.
Args:
read_only (bool): If True, the editor will be read-only.
"""
self.editor.set_readonly(read_only)
def set_theme(self, theme: str) -> None:
"""
Set the theme for the Monaco editor.
Args:
theme (str): The theme to set (e.g., "vs-dark", "light").
"""
self.editor.set_theme(theme)
def get_theme(self) -> str:
"""
Get the current theme of the Monaco editor.
"""
return self.editor.get_theme()
def set_minimap_enabled(self, enabled: bool) -> None:
"""
Enable or disable the minimap in the Monaco editor.
Args:
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
self.editor.set_minimap_enabled(enabled)
def set_highlighted_lines(self, start_line: int, end_line: int) -> None:
"""
Highlight a range of lines in the Monaco editor.
Args:
start_line (int): The starting line number (1-based).
end_line (int): The ending line number (1-based).
"""
self.editor.set_highlighted_lines(start_line, end_line)
def clear_highlighted_lines(self) -> None:
"""
Clear any highlighted lines in the Monaco editor.
"""
self.editor.clear_highlighted_lines()
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
self.editor.set_vim_mode_enabled(enabled)
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
self.editor.set_lsp_header(header)
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
return self.editor.get_lsp_header()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])
widget = MonacoWidget()
# set the default size
widget.resize(800, 600)
widget.set_language("python")
widget.set_theme("vs-dark")
widget.editor.set_minimap_enabled(False)
widget.set_text(
"""
import numpy as np
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bec_lib.devicemanager import DeviceContainer
from bec_lib.scans import Scans
dev: DeviceContainer
scans: Scans
#######################################
########## User Script #####################
#######################################
# This is a comment
def hello_world():
print("Hello, world!")
"""
)
widget.set_highlighted_lines(1, 3)
widget.show()
qapp.exec_()

View File

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

View File

@@ -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.editors.monaco.monaco_widget import MonacoWidget
DOM_XML = """
<ui language='c++'>
<widget class='MonacoWidget' name='monaco_widget'>
</widget>
</ui>
"""
class MonacoWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = MonacoWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Developer"
def icon(self):
return designer_material_icon(MonacoWidget.ICON_NAME)
def includeFile(self):
return "monaco_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 "MonacoWidget"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -1,15 +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.editors.monaco.monaco_widget_plugin import MonacoWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MonacoWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -2,7 +2,6 @@
# 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.editors.sbb_monitor.sbb_monitor import SBBMonitor
@@ -21,8 +20,6 @@ class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = SBBMonitor(parent)
return t
@@ -30,7 +27,7 @@ class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Utils"
return ""
def icon(self):
return designer_material_icon(SBBMonitor.ICON_NAME)

View File

@@ -2,7 +2,6 @@
# 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.editors.scan_metadata.scan_metadata import ScanMetadata
@@ -21,8 +20,6 @@ class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = ScanMetadata(parent)
return t
@@ -30,7 +27,7 @@ class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Input Widgets"
return ""
def icon(self):
return designer_material_icon(ScanMetadata.ICON_NAME)
@@ -51,7 +48,7 @@ class ScanMetadataPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "ScanMetadata"
def toolTip(self):
return "ScanMetadata"
return "Dynamically generates a form for inclusion of metadata for a scan."
def whatsThis(self):
return self.toolTip()

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.text_box.text_box import TextBox
@@ -13,6 +14,7 @@ DOM_XML = """
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
@@ -21,8 +23,6 @@ class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = TextBox(parent)
return t
@@ -51,7 +51,7 @@ class TextBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "TextBox"
def toolTip(self):
return "A widget that displays text in plain and HTML format"
return "TextBox"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,9 +1,10 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
@@ -14,6 +15,8 @@ DOM_XML = """
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
@@ -21,8 +24,6 @@ class VSCodeEditorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = VSCodeEditor(parent)
return t

View File

@@ -6,12 +6,11 @@ import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
@@ -150,7 +149,6 @@ _web_console_registry = WebConsoleRegistry()
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)
@@ -166,16 +164,11 @@ class WebConsole(BECWidget, QWidget):
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._startup_cmd = "bec --nogui"
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
@@ -187,48 +180,6 @@ class WebConsole(BECWidget, QWidget):
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
self._startup_timer = QTimer()
self._startup_timer.setInterval(500)
self._startup_timer.timeout.connect(self._check_page_ready)
self._startup_timer.start()
self._js_callback.connect(self._on_js_callback)
def _check_page_ready(self):
"""
Check if the page is ready and stop the timer if it is.
"""
if self.page.isLoading():
return
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
def _on_js_callback(self, ready: bool):
"""
Callback for when the JavaScript is ready.
"""
if not ready:
return
self._is_initialized = True
self._startup_timer.stop()
if self._startup_cmd:
self.write(self._startup_cmd)
self.initialized.emit()
@SafeProperty(str)
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the web console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
def write(self, data: str, send_return: bool = True):
"""
@@ -261,19 +212,10 @@ class WebConsole(BECWidget, QWidget):
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
self._startup_timer.stop()
_web_console_registry.unregister(self)
super().cleanup()

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