mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
236 Commits
v2.22.1
...
improve-de
| Author | SHA1 | Date | |
|---|---|---|---|
| 797a5046d1 | |||
| 9ff0db4831 | |||
| e4b909cca0 | |||
| d35f802d99 | |||
| e7ba29569d | |||
| 69568cdfd0 | |||
| 44943d5d10 | |||
| c766f4b84a | |||
| bc5424df09 | |||
| 1b35b1b36e | |||
| 920e7651b5 | |||
| 9c14289719 | |||
| 040275ac8b | |||
| 20c94697dd | |||
| 5e4d2ec0ef | |||
| 8294ef2449 | |||
| 148b387019 | |||
| 028ba6a684 | |||
| f9cc01408d | |||
| fb2d8ca9d3 | |||
| b65da75f1e | |||
| 0bb693a062 | |||
| 33c4527da9 | |||
| f89b330db3 | |||
| ae7f313fad | |||
| 5d148babe5 | |||
| 63a792aed9 | |||
| f9e21153b6 | |||
| 7bead79a96 | |||
| eee0ca92a7 | |||
| 688b1242e3 | |||
| e93b13ca79 | |||
| f293f1661a | |||
| 6a6fe41f8d | |||
| 73c46d47a3 | |||
| c7cd3c60b4 | |||
| 5cfaeb9efd | |||
| ced2213e4c | |||
| 77ea92cd1a | |||
| 53a230c719 | |||
| 66581b60d1 | |||
| e618c56c11 | |||
| b26a568b57 | |||
| 95a040522f | |||
| 499b4d5615 | |||
| b5c6d93cba | |||
| d92259e8c0 | |||
| c7a0f531d0 | |||
| e89cefed97 | |||
| 14d7f1fcad | |||
| 49b9cbf553 | |||
| 1803d3dd9d | |||
| a823dd243e | |||
| 34ed0daa98 | |||
| 7c9ba024bc | |||
| 8fd091ab44 | |||
| 84b892d7f0 | |||
| 97722bdde7 | |||
| 63c599db76 | |||
| 1adabb0955 | |||
| b1d2100e05 | |||
| 4420793cf3 | |||
| d2fede00d2 | |||
| ff4025c209 | |||
| 8f5d28a276 | |||
| 1a2ec920f6 | |||
| 098f2d4f6f | |||
| 706490247b | |||
| a0e190e38d | |||
| 9aae92aa89 | |||
| 35f3caf2dd | |||
| 37191aae62 | |||
| 1feeb11ab0 | |||
| ffa22242d0 | |||
| a32751d368 | |||
| f60939d231 | |||
| fc1e514883 | |||
| 9e2d0742ca | |||
| 16073dfd6d | |||
| 410fd517c5 | |||
| a25781d8d7 | |||
| 9488923381 | |||
| ad85472698 | |||
| 77eb21ac52 | |||
| 6f43917cc3 | |||
| e45d5da032 | |||
| 74f27ec2d9 | |||
| 296b858cdd | |||
| ab8dfd3811 | |||
| b6d4d5d749 | |||
| 5a6641f0f9 | |||
|
|
1d988a4c57 | ||
| 565c0bd1e7 | |||
| 975404f483 | |||
|
|
165e5e7d84 | ||
| 108ddae6ca | |||
|
|
9737acad58 | ||
| 65bc5f5421 | |||
| 475ca9f2d8 | |||
| bbb5fc6ce1 | |||
| b1b6c5e6a5 | |||
| 3e339348dd | |||
|
|
4f075151d5 | ||
| 0a24ac2c40 | |||
| 3a2ec9f1b7 | |||
| 4dc4ede1d2 | |||
| 556832fd48 | |||
| 72b6f74252 | |||
| b703b37bbd | |||
| 18ef35f22a | |||
| fe67a4f325 | |||
|
|
f1c3d77a45 | ||
| ad7cdc60dd | |||
|
|
ba047fd776 | ||
| 6e05157abb | |||
|
|
f4bc759e72 | ||
| 1bec9bd9b2 | |||
|
|
8b013d5dce | ||
| f2e5a85e61 | |||
|
|
a2f8880459 | ||
| 926d722955 | |||
| 44ba7201b4 | |||
|
|
0717426db2 | ||
| f4af6ebc5f | |||
| a923f12c97 | |||
| a5a7607a83 | |||
| 9de548446b | |||
| 49ac7decf7 | |||
|
|
092bed38fa | ||
| 50c84a766a | |||
| d22a3317ba | |||
| 6df1d0c31f | |||
| 946752a4b0 | |||
| c1f62ad6cb | |||
| a5adf3a97d | |||
|
|
76e3e0b60f | ||
| f18eeb9c5d | |||
| 32ce8e2818 | |||
| 23413cffab | |||
|
|
4bbb8fa519 | ||
|
|
a972369a72 | ||
| cd81e7f9ba | |||
|
|
e2b8118f67 | ||
| 5f925ba4e3 | |||
| fc68d2cf2d | |||
| 627b49b33a | |||
| a51ef04cdf | |||
| 40f4bce285 | |||
| 2b9fe6c959 | |||
| c2e16429c9 | |||
|
|
85ce2aa136 | ||
| fd5af01842 | |||
| 8a214c8978 | |||
|
|
f3214445f2 | ||
| 6bf84aea25 | |||
|
|
aace071f11 | ||
| bf86a030a0 | |||
|
|
358c979bf2 | ||
| c1bdc506e8 | |||
|
|
4febfb79df | ||
| 0854175acb | |||
| e090ac49b7 | |||
| e4521d9528 | |||
| 1d0490fff4 | |||
| 10cbb9a05c | |||
| 7073e75adf | |||
| e42ffd7c01 | |||
| 2bd6d00899 | |||
| c2a918ef4b | |||
| 6bbf5126cf | |||
| 728d4efd96 | |||
|
|
7926969996 | ||
| 61e5bde15f | |||
|
|
c8aa770de3 | ||
| 4d5df9608a | |||
| b718b438ba | |||
|
|
2f978c93c4 | ||
| b4e0664011 | |||
|
|
45fbf4015d | ||
|
|
0d81bdd4dd | ||
|
|
bb4c30ad80 | ||
| 3fd09fceef | |||
| 8eb8225a7f | |||
| 491d04467c | |||
|
|
3bcff75107 | ||
| 608590c542 | |||
|
|
012f7cf970 | ||
| cd17a4aad9 | |||
| f0dc992586 | |||
| fd1f9941e0 | |||
| 3384ca02bd | |||
| 959cedbbd5 | |||
| ca4f97503b | |||
| 22beadcad0 | |||
| b9af36a4f1 | |||
|
|
bdff736aa2 | ||
| 7cda2ed846 | |||
| cd9d22d0b4 | |||
|
|
37b80e16a0 | ||
| 7f0098f153 | |||
| 8489ef4a69 | |||
| 13976557fb | |||
|
|
06ad87ce0a | ||
| 00e3713181 | |||
|
|
62020f9965 | ||
| 2373c7e996 | |||
|
|
1f3566c105 | ||
| b8ae7b2e96 | |||
| 23674ccf59 | |||
| 1d8069e391 | |||
| 44cc06137c | |||
| 46a91784d2 | |||
| debd347b64 | |||
|
|
a13c3c44c8 | ||
| 25b2737aac | |||
| cf97cc1805 | |||
| 694a6c4960 | |||
| 9caae4cf40 | |||
| 2b06e34ecf | |||
| a9c8995ac0 | |||
|
|
1262c66fd6 | ||
| bde523806f | |||
|
|
16bca25d9c | ||
| 130cc24b35 | |||
| 8b2d6052e8 | |||
| 530797a556 | |||
| c660e5141f | |||
| 900153bc0b | |||
| 8dc72656ef | |||
| 170be0c7d3 | |||
| 1925e6ac7f | |||
|
|
b6cef2d27b | ||
| a9fce175b7 | |||
| 783d042e8c | |||
|
|
319a4206f2 | ||
| 76439866c1 |
64
.github/workflows/child_repos.yml
vendored
Normal file
64
.github/workflows/child_repos.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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'
|
||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -57,4 +57,24 @@ jobs:
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
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 }}
|
||||
2
.github/workflows/pytest-matrix.yml
vendored
2
.github/workflows/pytest-matrix.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
env:
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
|
||||
8
.github/workflows/pytest.yml
vendored
8
.github/workflows/pytest.yml
vendored
@@ -57,6 +57,14 @@ jobs:
|
||||
id: coverage
|
||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: image-references
|
||||
path: bec_widgets/tests/reference_failures/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
4
.github/workflows/stale-issues.yml
vendored
4
.github/workflows/stale-issues.yml
vendored
@@ -2,10 +2,14 @@ name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '00 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
|
||||
289
.gitlab-ci.yml
289
.gitlab-ci.yml
@@ -1,289 +0,0 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official language image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python/tags/
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
BEC_CORE_BRANCH:
|
||||
description: bec branch
|
||||
value: main
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: ophyd_devices branch
|
||||
value: main
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
CHECK_PKG_VERSIONS:
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
value: 0
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
- if: $CI_PIPELINE_SOURCE == "pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v,--random-order,tests/unit_tests"
|
||||
pip_args: ".[dev]"
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-repos: &install-repos
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
|
||||
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
|
||||
fi
|
||||
|
||||
formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install -e ./[dev]
|
||||
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
|
||||
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
allow_failure: true
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
# Fail the job if the pylint score is below 9
|
||||
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
generate-client-check:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- bw-generate-cli --target bec_widgets
|
||||
# if there are changes in the generated files, fail the job
|
||||
- git diff --exit-code
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,$QT_PCKG]
|
||||
- pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3:25.1.1-2
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths:
|
||||
- ./logs/*.log
|
||||
expire_in: 1 week
|
||||
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
needs: ["tests"]
|
||||
script:
|
||||
- git config --global user.name "ci_update_bot"
|
||||
- git config --global user.email "ci_update_bot@bec.ch"
|
||||
- git checkout "$CI_COMMIT_REF_NAME"
|
||||
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
|
||||
|
||||
# delete all local tags
|
||||
- git tag -l | xargs git tag -d
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- semantic-release -vv version
|
||||
|
||||
# check if any artifacts were created
|
||||
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
|
||||
- semantic-release publish
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
|
||||
pages:
|
||||
stage: Deploy
|
||||
needs: ["semver"]
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
script:
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
@@ -52,7 +52,7 @@ persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.10
|
||||
py-version=3.11
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
563
CHANGELOG.md
563
CHANGELOG.md
@@ -1,6 +1,569 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
@@ -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, UILaunchWindow
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -395,20 +395,24 @@ class LaunchWindow(BECMainWindow):
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindow()
|
||||
window = BECMainWindowNoRPC()
|
||||
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 the custom UI file
|
||||
"""
|
||||
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
|
||||
instantiate it directly; otherwise, embed it in a UILaunchWindow.
|
||||
"""
|
||||
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
|
||||
@@ -416,19 +420,22 @@ class LaunchWindow(BECMainWindow):
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
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."
|
||||
)
|
||||
# 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)
|
||||
|
||||
window = UILaunchWindow(object_name=filename)
|
||||
QApplication.processEvents()
|
||||
result_widget = UILoader(window).loader(ui_file)
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {window.object_name}")
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
window.show()
|
||||
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
|
||||
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
@@ -451,7 +458,7 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
window = BECMainWindow()
|
||||
window = BECMainWindowNoRPC()
|
||||
|
||||
widget_instance = widget(root_widget=True, object_name=name)
|
||||
assert isinstance(widget_instance, QWidget)
|
||||
|
||||
201
bec_widgets/applications/main_app.py
Normal file
201
bec_widgets/applications/main_app.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*args,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
show_examples: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self._show_examples = bool(show_examples)
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
self.stack = QStackedWidget(self)
|
||||
|
||||
container = QWidget(self)
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.sidebar, 0)
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(container)
|
||||
|
||||
# Mapping for view switching
|
||||
self._view_index: dict[str, int] = {}
|
||||
self._current_view_id: str | None = None
|
||||
self.sidebar.view_selected.connect(self._on_view_selected)
|
||||
|
||||
self._add_views()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(self)
|
||||
self.device_manager = DeviceManagerWidget(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
id="waveform_popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
id="waveform_stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
|
||||
def add_separator(self):
|
||||
return self.sidebar.add_separator()
|
||||
|
||||
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
|
||||
return self.sidebar.add_dark_mode_item(id=id, position=position)
|
||||
|
||||
def add_view(
|
||||
self,
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Register a view in the stack and create a matching nav item in the sidebar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the view/item.
|
||||
widget(QWidget): The widget to add to the stack.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
|
||||
|
||||
"""
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
)
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
if id in self._view_index:
|
||||
self.sidebar.activate_item(id)
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
|
||||
)
|
||||
|
||||
# Ask current view whether we may leave
|
||||
if current_view is not None and hasattr(current_view, "on_exit"):
|
||||
may_leave = current_view.on_exit()
|
||||
if may_leave is False:
|
||||
# Veto: restore previous highlight without re-emitting selection
|
||||
if self._current_view_id is not None:
|
||||
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
|
||||
return
|
||||
|
||||
# Proceed with switch
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
self.stack.setCurrentIndex(idx)
|
||||
new_view = self.stack.widget(idx)
|
||||
self._current_view_id = vid
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Main Application")
|
||||
parser.add_argument(
|
||||
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
|
||||
)
|
||||
# Let Qt consume the remaining args
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
w.resize(1920, 1200)
|
||||
w.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
|
||||
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
ANIMATION_DURATION = 500 # ms
|
||||
|
||||
|
||||
class RevealAnimator:
|
||||
"""Animate reveal/hide for a single widget using opacity + max W/H.
|
||||
|
||||
This keeps the widget always visible to avoid jitter from setVisible().
|
||||
Collapsed state: opacity=0, maxW=0, maxH=0.
|
||||
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: QWidget,
|
||||
duration: int = ANIMATION_DURATION,
|
||||
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
|
||||
initially_revealed: bool = False,
|
||||
*,
|
||||
animate_opacity: bool = True,
|
||||
animate_width: bool = True,
|
||||
animate_height: bool = True,
|
||||
):
|
||||
self.widget = widget
|
||||
self.animate_opacity = animate_opacity
|
||||
self.animate_width = animate_width
|
||||
self.animate_height = animate_height
|
||||
# Opacity effect
|
||||
self.fx = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(self.fx)
|
||||
# Animations
|
||||
self.opacity_anim = (
|
||||
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
|
||||
)
|
||||
self.width_anim = (
|
||||
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
|
||||
)
|
||||
self.height_anim = (
|
||||
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
|
||||
)
|
||||
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
|
||||
if anim is not None:
|
||||
anim.setDuration(duration)
|
||||
anim.setEasingCurve(easing)
|
||||
# Initialize to requested state
|
||||
self.set_immediate(initially_revealed)
|
||||
|
||||
def _natural_sizes(self) -> tuple[int, int]:
|
||||
sh = self.widget.sizeHint()
|
||||
w = max(sh.width(), 1)
|
||||
h = max(sh.height(), 1)
|
||||
return w, h
|
||||
|
||||
def set_immediate(self, revealed: bool):
|
||||
"""
|
||||
Immediately set the widget to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
revealed(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
w, h = self._natural_sizes()
|
||||
if self.animate_opacity:
|
||||
self.fx.setOpacity(1.0 if revealed else 0.0)
|
||||
if self.animate_width:
|
||||
self.widget.setMaximumWidth(w if revealed else 0)
|
||||
if self.animate_height:
|
||||
self.widget.setMaximumHeight(h if revealed else 0)
|
||||
|
||||
def setup(self, reveal: bool):
|
||||
"""
|
||||
Prepare animations to transition to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
reveal(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
# Prepare animations from current state to target
|
||||
target_w, target_h = self._natural_sizes()
|
||||
if self.opacity_anim is not None:
|
||||
self.opacity_anim.setStartValue(self.fx.opacity())
|
||||
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
|
||||
if self.width_anim is not None:
|
||||
self.width_anim.setStartValue(self.widget.maximumWidth())
|
||||
self.width_anim.setEndValue(target_w if reveal else 0)
|
||||
if self.height_anim is not None:
|
||||
self.height_anim.setStartValue(self.widget.maximumHeight())
|
||||
self.height_anim.setEndValue(target_h if reveal else 0)
|
||||
|
||||
def add_to_group(self, group: QParallelAnimationGroup):
|
||||
"""
|
||||
Add the prepared animations to the given animation group.
|
||||
|
||||
Args:
|
||||
group(QParallelAnimationGroup): The animation group to add to.
|
||||
"""
|
||||
if self.opacity_anim is not None:
|
||||
group.addAnimation(self.opacity_anim)
|
||||
if self.width_anim is not None:
|
||||
group.addAnimation(self.width_anim)
|
||||
if self.height_anim is not None:
|
||||
group.addAnimation(self.height_anim)
|
||||
|
||||
def animations(self):
|
||||
"""
|
||||
Get a list of all animations (non-None) for adding to a group.
|
||||
"""
|
||||
return [
|
||||
anim
|
||||
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
|
||||
if anim is not None
|
||||
]
|
||||
357
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
357
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import (
|
||||
DarkModeNavItem,
|
||||
NavigationItem,
|
||||
SectionHeader,
|
||||
SideBarSeparator,
|
||||
)
|
||||
|
||||
|
||||
class SideBar(QScrollArea):
|
||||
view_selected = Signal(str)
|
||||
toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Control Panel",
|
||||
collapsed_width: int = 56,
|
||||
expanded_width: int = 250,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("SideBar")
|
||||
|
||||
# private attributes
|
||||
self._is_expanded = False
|
||||
self._collapsed_width = collapsed_width
|
||||
self._expanded_width = expanded_width
|
||||
self._anim_duration = anim_duration
|
||||
|
||||
# containers
|
||||
self.components = {}
|
||||
self._item_opts: dict[str, dict] = {}
|
||||
|
||||
# Scroll area properties
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(self._collapsed_width)
|
||||
|
||||
# Content widget holding buttons for switching views
|
||||
self.content = QWidget(self)
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.content_layout.setSpacing(4)
|
||||
self.setWidget(self.content)
|
||||
|
||||
# Track active navigation item
|
||||
self._active_id = None
|
||||
|
||||
# Top row with title and toggle button
|
||||
self.toggle_row = QWidget(self)
|
||||
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
|
||||
|
||||
self.title_label = QLabel(title, self)
|
||||
self.title_label.setObjectName("TopTitle")
|
||||
self.title_label.setStyleSheet("font-weight: 600;")
|
||||
self.title_fx = QGraphicsOpacityEffect(self.title_label)
|
||||
self.title_label.setGraphicsEffect(self.title_fx)
|
||||
self.title_fx.setOpacity(0.0)
|
||||
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setCheckable(False)
|
||||
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
|
||||
self.toggle.clicked.connect(self.on_expand)
|
||||
|
||||
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
|
||||
# To push the content up always
|
||||
self._bottom_spacer = QtWidgets.QSpacerItem(
|
||||
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
|
||||
# Add core widgets to layout
|
||||
self.content_layout.addWidget(self.toggle_row)
|
||||
self.content_layout.addItem(self._bottom_spacer)
|
||||
|
||||
# Animations
|
||||
self.width_anim = QPropertyAnimation(self, b"bar_width")
|
||||
self.width_anim.setDuration(self._anim_duration)
|
||||
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
|
||||
self.title_anim.setDuration(self._anim_duration)
|
||||
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.group = QParallelAnimationGroup(self)
|
||||
self.group.addAnimation(self.width_anim)
|
||||
self.group.addAnimation(self.title_anim)
|
||||
self.group.finished.connect(self._on_anim_finished)
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
|
||||
app.theme.theme_changed.connect(self._on_theme_changed)
|
||||
|
||||
@SafeProperty(int)
|
||||
def bar_width(self) -> int:
|
||||
"""
|
||||
Get the current width of the side bar.
|
||||
|
||||
Returns:
|
||||
int: The current width of the side bar.
|
||||
"""
|
||||
return self.width()
|
||||
|
||||
@bar_width.setter
|
||||
def bar_width(self, width: int):
|
||||
"""
|
||||
Set the width of the side bar.
|
||||
|
||||
Args:
|
||||
width(int): The new width of the side bar.
|
||||
"""
|
||||
self.setFixedWidth(width)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def is_expanded(self) -> bool:
|
||||
"""
|
||||
Check if the side bar is expanded.
|
||||
|
||||
Returns:
|
||||
bool: True if the side bar is expanded, False otherwise.
|
||||
"""
|
||||
return self._is_expanded
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def on_expand(self):
|
||||
"""
|
||||
Toggle the expansion state of the side bar.
|
||||
"""
|
||||
self._is_expanded = not self._is_expanded
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
|
||||
if self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.group.stop()
|
||||
# Setting limits for animations of the side bar
|
||||
self.width_anim.setStartValue(self.width())
|
||||
self.width_anim.setEndValue(
|
||||
self._expanded_width if self._is_expanded else self._collapsed_width
|
||||
)
|
||||
self.title_anim.setStartValue(self.title_fx.opacity())
|
||||
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
|
||||
|
||||
# Setting limits for animations of the components
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "setup_animations"):
|
||||
comp.setup_animations(self._is_expanded)
|
||||
|
||||
self.group.start()
|
||||
if self._is_expanded:
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
self.toggled.emit(self._is_expanded)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_anim_finished(self):
|
||||
if not self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_theme_changed(self, theme_name: str):
|
||||
# Refresh toggle arrow icon so it picks up the new theme
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
# Refresh each component that supports it
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "refresh_theme"):
|
||||
comp.refresh_theme()
|
||||
else:
|
||||
comp.style().unpolish(comp)
|
||||
comp.style().polish(comp)
|
||||
comp.update()
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
|
||||
"""
|
||||
Add a section header to the side bar.
|
||||
|
||||
Args:
|
||||
title(str): The title of the section.
|
||||
id(str): Unique ID for the section.
|
||||
position(int, optional): Position to insert the section header.
|
||||
|
||||
Returns:
|
||||
SectionHeader: The created section header.
|
||||
|
||||
"""
|
||||
header = SectionHeader(self, title, anim_duration=self._anim_duration)
|
||||
position = position if position is not None else self.content_layout.count() - 1
|
||||
self.content_layout.insertWidget(position, header)
|
||||
for anim in header.animations:
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = header
|
||||
return header
|
||||
|
||||
def add_separator(
|
||||
self, *, from_top: bool = True, position: int | None = None
|
||||
) -> SideBarSeparator:
|
||||
"""
|
||||
Add a separator line to the side bar. Separators are treated like regular
|
||||
items; you can place multiple separators anywhere using `from_top` and `position`.
|
||||
"""
|
||||
line = SideBarSeparator(self)
|
||||
line.setStyleSheet("margin:12px;")
|
||||
self._insert_nav_item(line, from_top=from_top, position=position)
|
||||
return line
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Add a navigation item to the side bar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the nav item.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
"""
|
||||
item = NavigationItem(
|
||||
parent=self,
|
||||
title=title,
|
||||
icon_name=icon,
|
||||
mini_text=mini_text,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
anim_duration=self._anim_duration,
|
||||
)
|
||||
self._insert_nav_item(item, from_top=from_top, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
# Connect activation to activation logic, passing id unchanged
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def activate_item(self, target_id: str, *, emit_signal: bool = True):
|
||||
target = self.components.get(target_id)
|
||||
if target is None:
|
||||
return
|
||||
# Non-toggleable acts like an action: do not change any toggled states
|
||||
if hasattr(target, "toggleable") and not target.toggleable:
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
return
|
||||
|
||||
is_exclusive = getattr(target, "exclusive", True)
|
||||
if is_exclusive:
|
||||
# Radio-like behavior among exclusive items only
|
||||
for comp_id, comp in self.components.items():
|
||||
if not isinstance(comp, NavigationItem):
|
||||
continue
|
||||
if comp is target:
|
||||
comp.set_active(True)
|
||||
else:
|
||||
# Only untoggle other items that are also exclusive
|
||||
if getattr(comp, "exclusive", True):
|
||||
comp.set_active(False)
|
||||
# Leave non-exclusive items as they are
|
||||
else:
|
||||
# Non-exclusive toggles independently
|
||||
target.set_active(not target.is_active())
|
||||
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
|
||||
def add_dark_mode_item(
|
||||
self, id: str = "dark_mode", position: int | None = None
|
||||
) -> DarkModeNavItem:
|
||||
"""
|
||||
Add a dark mode toggle item to the side bar.
|
||||
|
||||
Args:
|
||||
id(str): Unique ID for the dark mode item.
|
||||
position(int, optional): Position to insert the dark mode item.
|
||||
|
||||
Returns:
|
||||
DarkModeNavItem: The created dark mode navigation item.
|
||||
"""
|
||||
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
|
||||
# compute bottom insertion point (same semantics as from_top=False)
|
||||
self._insert_nav_item(item, from_top=False, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def _insert_nav_item(
|
||||
self, item: QWidget, *, from_top: bool = True, position: int | None = None
|
||||
):
|
||||
if from_top:
|
||||
base_index = self.content_layout.indexOf(self._bottom_spacer)
|
||||
pos = base_index if position is None else min(base_index, position)
|
||||
else:
|
||||
base = self.content_layout.indexOf(self._bottom_spacer) + 1
|
||||
pos = base if position is None else base + max(0, position)
|
||||
self.content_layout.insertWidget(pos, item)
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import (
|
||||
ANIMATION_DURATION,
|
||||
RevealAnimator,
|
||||
)
|
||||
|
||||
|
||||
def get_on_primary():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("ON_PRIMARY")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def get_fg():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("FG")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
class SideBarSeparator(QFrame):
|
||||
"""A horizontal line separator for use in SideBar."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SideBarSeparator")
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setFixedHeight(2)
|
||||
self.setProperty("variant", "separator")
|
||||
|
||||
|
||||
class SectionHeader(QWidget):
|
||||
"""A section header with a label and a horizontal line below."""
|
||||
|
||||
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SectionHeader")
|
||||
|
||||
self.lbl = QLabel(text, self)
|
||||
self.lbl.setObjectName("SectionHeaderLabel")
|
||||
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
|
||||
|
||||
self.line = SideBarSeparator(self)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
# keep your margins/spacing preferences here if needed
|
||||
lay.setContentsMargins(12, 0, 12, 0)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.lbl)
|
||||
lay.addWidget(self.line)
|
||||
|
||||
self.animations = self.build_animations()
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return self._reveal.animations()
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self._reveal.setup(expanded)
|
||||
|
||||
|
||||
class NavigationItem(QWidget):
|
||||
"""A nav tile with an icon + labels and an optional expandable body.
|
||||
Provides animations for collapsed/expanded sidebar states via
|
||||
build_animations()/setup_animations(), similar to SectionHeader.
|
||||
"""
|
||||
|
||||
activated = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
title: str,
|
||||
icon_name: str,
|
||||
mini_text: str | None = None,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NavigationItem")
|
||||
|
||||
# Private attributes
|
||||
self._title = title
|
||||
self._icon_name = icon_name
|
||||
self._mini_text = mini_text or title
|
||||
self._toggleable = toggleable
|
||||
self._toggled = False
|
||||
self._exclusive = exclusive
|
||||
|
||||
# Main Icon
|
||||
self.icon_btn = QToolButton(self)
|
||||
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
|
||||
self.icon_btn.setAutoRaise(True)
|
||||
self._icon_size_collapsed = QtCore.QSize(20, 20)
|
||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||
# Remove QToolButton hover/pressed background/outline
|
||||
self.icon_btn.setStyleSheet(
|
||||
"""
|
||||
QToolButton:hover { background: transparent; border: none; }
|
||||
QToolButton:pressed { background: transparent; border: none; }
|
||||
"""
|
||||
)
|
||||
|
||||
# Mini label below icon
|
||||
self.mini_lbl = QLabel(self._mini_text, self)
|
||||
self.mini_lbl.setObjectName("NavMiniLabel")
|
||||
self.mini_lbl.setAlignment(Qt.AlignCenter)
|
||||
self.mini_lbl.setStyleSheet("font-size: 10px;")
|
||||
self.reveal_mini_lbl = RevealAnimator(
|
||||
widget=self.mini_lbl,
|
||||
initially_revealed=True,
|
||||
animate_width=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
|
||||
# Container for icon + mini label
|
||||
self.mini_icon = QWidget(self)
|
||||
mini_lay = QVBoxLayout(self.mini_icon)
|
||||
mini_lay.setContentsMargins(0, 2, 0, 2)
|
||||
mini_lay.setSpacing(2)
|
||||
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
|
||||
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
|
||||
|
||||
# Title label
|
||||
self.title_lbl = QLabel(self._title, self)
|
||||
self.title_lbl.setObjectName("NavTitleLabel")
|
||||
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.title_lbl.setStyleSheet("font-size: 13px;")
|
||||
self.reveal_title_lbl = RevealAnimator(
|
||||
widget=self.title_lbl,
|
||||
initially_revealed=False,
|
||||
animate_height=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(12, 2, 12, 2)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
|
||||
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
|
||||
self.icon_size_anim.setDuration(anim_duration)
|
||||
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
# Connect icon button to emit activation
|
||||
self.icon_btn.clicked.connect(self._emit_activated)
|
||||
self.setMouseTracking(True)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return whether the item is currently active/selected."""
|
||||
return self.property("toggled") is True
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return (
|
||||
self.reveal_title_lbl.animations()
|
||||
+ self.reveal_mini_lbl.animations()
|
||||
+ [self.icon_size_anim]
|
||||
)
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self.reveal_mini_lbl.setup(not expanded)
|
||||
self.reveal_title_lbl.setup(expanded)
|
||||
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
|
||||
self.icon_size_anim.setEndValue(
|
||||
self._icon_size_expanded if expanded else self._icon_size_collapsed
|
||||
)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Set visibility of the title label."""
|
||||
self.title_lbl.setVisible(visible)
|
||||
|
||||
def _emit_activated(self):
|
||||
self.activated.emit()
|
||||
|
||||
def set_active(self, active: bool):
|
||||
"""
|
||||
Set the active/selected state of the item.
|
||||
|
||||
Args:
|
||||
active(bool): True to set active, False to deactivate.
|
||||
"""
|
||||
self.setProperty("toggled", active)
|
||||
self.toggled = active
|
||||
# ensure style refresh
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.activated.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggleable(self) -> bool:
|
||||
"""
|
||||
Whether the item is toggleable (like a button) or not (like an action).
|
||||
|
||||
Returns:
|
||||
bool: True if toggleable, False otherwise.
|
||||
"""
|
||||
return self._toggleable
|
||||
|
||||
@toggleable.setter
|
||||
def toggleable(self, value: bool):
|
||||
"""
|
||||
Set whether the item is toggleable (like a button) or not (like an action).
|
||||
Args:
|
||||
value(bool): True to make toggleable, False otherwise.
|
||||
"""
|
||||
self._toggleable = bool(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggled(self) -> bool:
|
||||
"""
|
||||
Whether the item is currently toggled/selected.
|
||||
|
||||
Returns:
|
||||
bool: True if toggled, False otherwise.
|
||||
"""
|
||||
return self._toggled
|
||||
|
||||
@toggled.setter
|
||||
def toggled(self, value: bool):
|
||||
"""
|
||||
Set whether the item is currently toggled/selected.
|
||||
|
||||
Args:
|
||||
value(bool): True to set toggled, False to untoggle.
|
||||
"""
|
||||
self._toggled = value
|
||||
if value:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
|
||||
)
|
||||
else:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
|
||||
)
|
||||
self.icon_btn.setIcon(new_icon)
|
||||
# Re-polish so QSS applies correct colors to icon/labels
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
Whether the item is exclusive in its toggle group.
|
||||
|
||||
Returns:
|
||||
bool: True if exclusive, False otherwise.
|
||||
"""
|
||||
return self._exclusive
|
||||
|
||||
@exclusive.setter
|
||||
def exclusive(self, value: bool):
|
||||
"""
|
||||
Set whether the item is exclusive in its toggle group.
|
||||
|
||||
Args:
|
||||
value(bool): True to make exclusive, False otherwise.
|
||||
"""
|
||||
self._exclusive = bool(value)
|
||||
|
||||
def refresh_theme(self):
|
||||
# Recompute icon/label colors according to current theme and state
|
||||
# Trigger the toggled setter to rebuild the icon with the correct color
|
||||
self.toggled = self._toggled
|
||||
# Ensure QSS-driven text/icon colors refresh
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
|
||||
class DarkModeNavItem(NavigationItem):
|
||||
"""Bottom action item that toggles app theme and updates its icon/text."""
|
||||
|
||||
def __init__(
|
||||
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
title="Dark mode",
|
||||
icon_name="dark_mode",
|
||||
mini_text="Dark",
|
||||
toggleable=False, # action-like, no selection highlight changes
|
||||
exclusive=False,
|
||||
anim_duration=anim_duration,
|
||||
)
|
||||
self._id = id
|
||||
self._sync_from_qapp_theme()
|
||||
self.activated.connect(self.toggle_theme)
|
||||
|
||||
def _qapp_dark_enabled(self) -> bool:
|
||||
qapp = QApplication.instance()
|
||||
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
|
||||
|
||||
def _sync_from_qapp_theme(self):
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
# Update labels
|
||||
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
|
||||
self.mini_lbl.setText("Light" if is_dark else "Dark")
|
||||
# Update icon
|
||||
self.icon_btn.setIcon(
|
||||
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
|
||||
)
|
||||
|
||||
def refresh_theme(self):
|
||||
self._sync_from_qapp_theme()
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
def toggle_theme(self):
|
||||
"""Toggle application theme and update icon/text."""
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
|
||||
apply_theme("light" if is_dark else "dark")
|
||||
self._sync_from_qapp_theme()
|
||||
0
bec_widgets/applications/views/__init__.py
Normal file
0
bec_widgets/applications/views/__init__.py
Normal file
@@ -0,0 +1,687 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Qt, QThreadPool, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTableView,
|
||||
DMConfigView,
|
||||
DMOphydTest,
|
||||
DocstringView,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
|
||||
AvailableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
PresetClassDeviceConfigDialog,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
REPLACE = 1
|
||||
ADD = 2
|
||||
CANCEL = 0
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel("Do you want to replace the current config or add to it?")
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Buttons: equal size, stacked vertically
|
||||
self.replace_btn = QPushButton("Replace")
|
||||
self.add_btn = QPushButton("Add")
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
btn_layout = QHBoxLayout()
|
||||
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
|
||||
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.replace_btn.clicked.connect(self.accept_replace)
|
||||
self.add_btn.clicked.connect(self.accept_add)
|
||||
self.cancel_btn.clicked.connect(self.reject_cancel)
|
||||
|
||||
self._result = self.CANCEL
|
||||
|
||||
def accept_replace(self):
|
||||
self._result = self.REPLACE
|
||||
self.accept()
|
||||
|
||||
def accept_add(self):
|
||||
self._result = self.ADD
|
||||
self.accept()
|
||||
|
||||
def reject_cancel(self):
|
||||
self._result = self.CANCEL
|
||||
self.reject()
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
|
||||
class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, client=None, *args, **kwargs)
|
||||
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# # Available Resources Widget
|
||||
# self.available_devices = AvailableDeviceResources(
|
||||
# self, shared_selection_signal=self._shared_selection
|
||||
# )
|
||||
# self.available_devices_dock = QtAds.CDockWidget(
|
||||
# self.dock_manager, "Available Devices", self
|
||||
# )
|
||||
# self.available_devices_dock.setWidget(self.available_devices)
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTableView(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
|
||||
self.device_table_view_dock.setWidget(self.device_table_view)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
|
||||
self.dm_config_view_dock.setWidget(self.dm_config_view)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
|
||||
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_test_view = DMOphydTest(self)
|
||||
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
|
||||
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
|
||||
|
||||
# Help Inspector
|
||||
widget = QWidget(self)
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.help_inspector = HelpInspector(self)
|
||||
layout.addWidget(self.help_inspector)
|
||||
text_box = QTextEdit(self)
|
||||
text_box.setReadOnly(False)
|
||||
text_box.setPlaceholderText("Help text will appear here...")
|
||||
layout.addWidget(text_box)
|
||||
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
|
||||
self.help_inspector_dock.setWidget(widget)
|
||||
|
||||
# # Hook inspector signals
|
||||
# def _class_cb(text: str):
|
||||
# print(text)
|
||||
|
||||
# # Register callback
|
||||
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
|
||||
|
||||
# Error Logs View
|
||||
self.error_logs_view = QTextEdit(self)
|
||||
self.error_logs_view.setReadOnly(True)
|
||||
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
|
||||
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
|
||||
self.error_logs_dock.setWidget(self.error_logs_view)
|
||||
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
|
||||
|
||||
# Arrange widgets within the QtAds dock manager
|
||||
# Central widget area
|
||||
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
|
||||
# Right area - should be pushed into view if something is active
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.ophyd_test_dock_view,
|
||||
self.central_dock_area,
|
||||
)
|
||||
# create bottom area (2-arg -> area)
|
||||
self.bottom_dock_area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
|
||||
)
|
||||
|
||||
# YAML view left of docstrings (docks relative to bottom area)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
|
||||
)
|
||||
|
||||
# Error/help area right of docstrings (dock relative to bottom area)
|
||||
area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.help_inspector_dock,
|
||||
self.bottom_dock_area,
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
|
||||
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
# TODO decide if we like to hide the title bars..
|
||||
# Fetch all dock areas of the dock widgets (on our case always one dock area)
|
||||
# for dock in self.dock_manager.dockWidgets():
|
||||
# if dock.objectName() in ["Help Inspector", "Error Logs"]:
|
||||
# continue
|
||||
# area = dock.dockAreaWidget()
|
||||
# area.titleBar().setVisible(False)
|
||||
|
||||
# Apply stretch after the layout is done
|
||||
self.set_default_view([2, 8, 2], [7, 3])
|
||||
# self.set_default_view([2, 8, 2], [2, 2, 4])
|
||||
|
||||
# Connect slots
|
||||
for signal, slots in [
|
||||
(
|
||||
self.device_table_view.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
# (
|
||||
# self.available_devices.selected_devices,
|
||||
# (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
# ),
|
||||
(
|
||||
self.ophyd_test_view.device_validated,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
# (
|
||||
# self.device_table_view.device_configs_changed,
|
||||
# (
|
||||
# self.ophyd_test_view.change_device_configs,
|
||||
# self.available_devices.mark_devices_used,
|
||||
# ),
|
||||
# ),
|
||||
# (
|
||||
# self.available_devices.add_selected_devices,
|
||||
# (self.device_table_view.add_device_configs,),
|
||||
# ),
|
||||
# (
|
||||
# self.available_devices.del_selected_devices,
|
||||
# (self.device_table_view.remove_device_configs,),
|
||||
# ),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
self._add_toolbar()
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
# Add IO actions
|
||||
self._add_io_actions()
|
||||
self._add_table_actions()
|
||||
self.toolbar.show_bundles(["IO", "Table"])
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
def _add_io_actions(self):
|
||||
# Create IO bundle
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
# Load from disk
|
||||
load = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
label_text="Load Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
io_bundle.add_action("load")
|
||||
|
||||
# Add safe to disk
|
||||
safe_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
label_text="Save Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("safe_to_disk", safe_to_disk)
|
||||
safe_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
io_bundle.add_action("safe_to_disk")
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from Redis",
|
||||
label_text="Get Current Config",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
io_bundle.add_action("load_redis")
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in Redis",
|
||||
label_text="Update Config",
|
||||
)
|
||||
update_config_redis.action.setEnabled(False)
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
io_bundle.add_action("update_config_redis")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
|
||||
# Table actions
|
||||
|
||||
def _add_table_actions(self) -> None:
|
||||
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config",
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="add",
|
||||
parent=self,
|
||||
tooltip="Add new device",
|
||||
label_text="Add Device",
|
||||
)
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="remove",
|
||||
parent=self,
|
||||
tooltip="Remove device",
|
||||
label_text="Remove Device",
|
||||
)
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Validate Connection",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
table_bundle.add_action("rerun_validation")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
# Most likly, no actions on available devices
|
||||
# Actions (vielleicht bundle fuer available devices )
|
||||
# - reset composed view
|
||||
# - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice)
|
||||
# - remove device
|
||||
# - rerun validation (with/without connect)
|
||||
|
||||
# IO actions
|
||||
def _coming_soon(self):
|
||||
return QMessageBox.question(
|
||||
self,
|
||||
"Not implemented yet",
|
||||
"This feature has not been implemented yet, will be coming soon...!!",
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = self._get_recovery_config_path()
|
||||
logger.warning(
|
||||
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||
)
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
"""
|
||||
Load device config from a given file path and update the device table view.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the configuration file.
|
||||
"""
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
if dialog.exec():
|
||||
if dialog.result() == ConfigChoiceDialog.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif dialog.result() == ConfigChoiceDialog.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
# TODO would we ever like to add the current config to an existing composition
|
||||
@SafeSlot()
|
||||
def _load_redis_action(self):
|
||||
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self):
|
||||
"""Action to push the current composition to Redis"""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Push composition to Redis",
|
||||
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
if self.device_table_view.table.contains_invalid_devices():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has errors!", "Please resolve before proceeding."
|
||||
)
|
||||
if self.ophyd_test_view.validation_running():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has not completed.", "Please wait for the validation to finish."
|
||||
)
|
||||
self._push_compositiion_to_redis()
|
||||
|
||||
def _push_compositiion_to_redis(self):
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
|
||||
threadpool.start(comm)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'safe_to_disk' action to save the current config to disk."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
config_path = self._get_recovery_config_path()
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
|
||||
|
||||
# Implement the file loading logic here
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, caption="Save Config File", dir=config_path
|
||||
)
|
||||
if file_path:
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
with open(file_path, "w") as file:
|
||||
file.write(yaml.dump(config))
|
||||
|
||||
# Table actions
|
||||
|
||||
@SafeSlot()
|
||||
def _reset_composed_view(self):
|
||||
"""Action for the 'reset_composed_view' action to reset the composed view."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Clear View",
|
||||
"You are about to clear the current composed config view, please confirm...",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.clear_device_configs()
|
||||
|
||||
# TODO We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device
|
||||
# For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc..
|
||||
# For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required..
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
# Implement the logic to add a new device
|
||||
dialog = PresetClassDeviceConfigDialog(parent=self)
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _add_to_table_from_dialog(self, data):
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
self.device_table_view.remove_selected_rows()
|
||||
|
||||
# TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations
|
||||
# in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'!
|
||||
@SafeSlot()
|
||||
def _rerun_validation_action(self):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = self.device_table_view.table.selected_configs()
|
||||
self.ophyd_test_view.change_device_configs(configs, True, True)
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(
|
||||
self, horizontal_weights: list, vertical_weights: list
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(
|
||||
self, *, horizontal=None, vertical=None
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
def _get_recovery_config_path(self) -> str:
|
||||
"""Get the recovery config path from the log_writer config."""
|
||||
# pylint: disable=protected-access
|
||||
log_writer_config = self.client._service_config.config.get("log_writer", {})
|
||||
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
l = QVBoxLayout()
|
||||
w.setLayout(l)
|
||||
apply_theme("dark")
|
||||
button = DarkModeButton()
|
||||
l.addWidget(button)
|
||||
device_manager_view = DeviceManagerView()
|
||||
l.addWidget(device_manager_view)
|
||||
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
# cfg = yaml_load(config_path)
|
||||
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||
|
||||
# # config = device_manager_view.client.device_manager._get_redis_device_config()
|
||||
# device_manager_view.device_table_view.set_device_config(cfg)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
w.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Top Level wrapper for device_manager widget"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Add device manager view
|
||||
self.device_manager_view = DeviceManagerView()
|
||||
self.stacked_layout.addWidget(self.device_manager_view)
|
||||
|
||||
# Add overlay widget
|
||||
self._overlay_widget = QtWidgets.QWidget(self)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QtWidgets.QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setLayout(self._overlay_layout)
|
||||
self._overlay_widget.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||
)
|
||||
# Load current config
|
||||
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
|
||||
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
|
||||
self.button_load_current_config.setIcon(icon)
|
||||
self._overlay_layout.addWidget(self.button_load_current_config)
|
||||
self.button_load_current_config.clicked.connect(self._load_config_clicked)
|
||||
# Load config from disk
|
||||
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
|
||||
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
|
||||
self.button_load_config_from_file.setIcon(icon)
|
||||
self._overlay_layout.addWidget(self.button_load_config_from_file)
|
||||
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
|
||||
self._overlay_widget.setVisible(True)
|
||||
|
||||
def _load_config_from_file_clicked(self):
|
||||
"""Handle click on 'Load Config From File' button."""
|
||||
start_dir = os.path.expanduser("~")
|
||||
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
try:
|
||||
config = yaml_load(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
config_list = []
|
||||
for name, cfg in config.items():
|
||||
config_list.append(cfg)
|
||||
config_list[-1]["name"] = name
|
||||
self.device_manager_view.device_table_view.set_device_config(config_list)
|
||||
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
self.device_manager_view.device_table_view.set_device_config(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
apply_theme("light")
|
||||
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager = DeviceManagerWidget()
|
||||
# config = device_manager.client.device_manager._get_redis_device_config()
|
||||
# device_manager.device_table_view.set_device_config(config)
|
||||
layout.addWidget(device_manager)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
dark_mode_button = DarkModeButton()
|
||||
layout.addWidget(dark_mode_button)
|
||||
widget.show()
|
||||
device_manager.setWindowTitle("Device Manager View")
|
||||
device_manager.resize(1600, 1200)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
262
bec_widgets/applications/views/view.py
Normal file
262
bec_widgets/applications/views/view.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
|
||||
if content is not None:
|
||||
self.set_content(content)
|
||||
|
||||
def set_content(self, content: QWidget) -> None:
|
||||
"""Replace the current content widget with a new one."""
|
||||
if self.content is not None:
|
||||
self.content.setParent(None)
|
||||
self.content = content
|
||||
self.layout().addWidget(content)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
"""Called before the view is switched away/hidden.
|
||||
|
||||
Return True to allow switching, or False to veto.
|
||||
Default implementation allows switching.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
####################################################################################################
|
||||
|
||||
|
||||
# --- Popup UI version ---
|
||||
class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
self.waveform = Waveform(parent=self)
|
||||
self.set_content(self.waveform)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Configure Waveform View")
|
||||
|
||||
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
|
||||
|
||||
# same as in the CurveRow used in waveform
|
||||
self.device_edit = DeviceComboBox(parent=self)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.entry_edit = SignalComboBox(parent=self)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
|
||||
v = QVBoxLayout(dialog)
|
||||
v.addLayout(form)
|
||||
v.addWidget(buttons)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
"Switch and clear?",
|
||||
"Do you want to switch views and clear the plot?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if ans == QMessageBox.Yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Inline stacked UI version ---
|
||||
class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Root layout for this view uses a stacked layout
|
||||
self.stack = QStackedLayout()
|
||||
container = QWidget(self)
|
||||
container.setLayout(self.stack)
|
||||
self.set_content(container)
|
||||
|
||||
# --- Page 0: Settings page (inline form)
|
||||
self.settings_page = QWidget()
|
||||
sp_layout = QVBoxLayout(self.settings_page)
|
||||
sp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
sp_layout.setSpacing(12)
|
||||
|
||||
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
|
||||
self.device_edit = DeviceComboBox(parent=self.settings_page)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
|
||||
self.entry_edit = SignalComboBox(parent=self.settings_page)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(title)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK", parent=self.settings_page)
|
||||
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addWidget(ok_btn)
|
||||
|
||||
sp_layout.addLayout(form)
|
||||
sp_layout.addLayout(btn_row)
|
||||
|
||||
# --- Page 1: Waveform page
|
||||
self.waveform_page = QWidget()
|
||||
wf_layout = QVBoxLayout(self.waveform_page)
|
||||
wf_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.waveform = Waveform(parent=self.waveform_page)
|
||||
wf_layout.addWidget(self.waveform)
|
||||
|
||||
# --- Page 2: Exit confirmation page (inline)
|
||||
self.confirm_page = QWidget()
|
||||
cp_layout = QVBoxLayout(self.confirm_page)
|
||||
cp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
cp_layout.setSpacing(12)
|
||||
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
|
||||
cp_buttons = QHBoxLayout()
|
||||
no_btn = QPushButton("No", parent=self.confirm_page)
|
||||
yes_btn = QPushButton("Yes", parent=self.confirm_page)
|
||||
cp_buttons.addStretch(1)
|
||||
cp_buttons.addWidget(no_btn)
|
||||
cp_buttons.addWidget(yes_btn)
|
||||
cp_layout.addWidget(qlabel)
|
||||
cp_layout.addLayout(cp_buttons)
|
||||
|
||||
# Add pages to the stack
|
||||
self.stack.addWidget(self.settings_page) # index 0
|
||||
self.stack.addWidget(self.waveform_page) # index 1
|
||||
self.stack.addWidget(self.confirm_page) # index 2
|
||||
|
||||
# Wire settings buttons
|
||||
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
|
||||
cancel_btn.clicked.connect(self._show_waveform_without_changes)
|
||||
|
||||
# Prepare result holder for the inline confirmation
|
||||
self._exit_choice_yes = None
|
||||
yes_btn.clicked.connect(lambda: self._exit_reply(True))
|
||||
no_btn.clicked.connect(lambda: self._exit_reply(False))
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
# Always start on the settings page when entering
|
||||
self.stack.setCurrentIndex(0)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
# Show inline confirmation page and synchronously wait for a choice
|
||||
# -> trick to make the choice blocking, however popup would be cleaner solution
|
||||
self._exit_choice_yes = None
|
||||
self.stack.setCurrentIndex(2)
|
||||
loop = QEventLoop()
|
||||
self._exit_loop = loop
|
||||
loop.exec_()
|
||||
|
||||
if self._exit_choice_yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
# Revert to waveform view if user cancelled switching
|
||||
self.stack.setCurrentIndex(1)
|
||||
return False
|
||||
|
||||
def _apply_settings_and_show_waveform(self):
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
# Just show waveform page without plotting
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _exit_reply(self, yes: bool):
|
||||
self._exit_choice_yes = bool(yes)
|
||||
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
|
||||
self._exit_loop.quit()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,21 @@ 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_from
|
||||
from bec_lib.utils.import_utils import lazy_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
|
||||
|
||||
@@ -51,7 +54,7 @@ def _filter_output(output: str) -> str:
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
@@ -151,8 +154,10 @@ def wait_for_server(client: BECGuiClient):
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
if client._gui_started_timer is not None:
|
||||
# cancel the timer, we are done
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
@@ -261,18 +266,37 @@ 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):
|
||||
"""Show the GUI window."""
|
||||
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.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
return self.start(wait=True)
|
||||
return self._start(wait=wait)
|
||||
|
||||
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,
|
||||
@@ -382,6 +406,9 @@ 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
|
||||
@@ -428,8 +455,8 @@ class BECGuiClient(RPCBase):
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
@@ -439,11 +466,24 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _do_raise_all(self):
|
||||
"""Bring GUI windows to the front."""
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
return self._do_raise_all()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
@@ -524,7 +564,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.start(wait=True)
|
||||
gui.show(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
|
||||
@@ -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
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
{"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,7 +180,10 @@ 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:
|
||||
@@ -205,14 +208,26 @@ class {class_name}(RPCBase):"""
|
||||
def {method}{str(sig_overload)}: ...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
@rpc_call"""
|
||||
self.content += f"""
|
||||
{self._rpc_call(timeout)}"""
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -24,6 +25,43 @@ 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.
|
||||
@@ -47,15 +85,7 @@ def rpc_call(func):
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
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
|
||||
args, kwargs = _transform_args_kwargs(args, kwargs)
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
@@ -172,6 +202,11 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
|
||||
def raise_window(self):
|
||||
"""Bring this widget (or its container) to the front."""
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
@@ -195,6 +230,12 @@ class RPCBase:
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
gui_id = obj.get("container_proxy") # type: ignore
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
|
||||
@@ -7,8 +7,11 @@ import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
@@ -91,6 +94,11 @@ class GUIServer:
|
||||
Run the GUI server.
|
||||
"""
|
||||
self.app = QApplication(sys.argv)
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
else:
|
||||
apply_theme("light")
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.setup_bec_icon()
|
||||
@@ -142,6 +150,8 @@ 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()
|
||||
|
||||
@@ -15,7 +15,9 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
@@ -44,6 +46,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
"ads": self.ads,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
@@ -120,14 +123,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(1)
|
||||
#
|
||||
# seventh_tab = QWidget()
|
||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
# self.scatter = ScatterWaveform()
|
||||
# self.scatter_mi = self.scatter.main_curve
|
||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
||||
# seventh_tab_layout.addWidget(self.scatter)
|
||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
# tab_widget.setCurrentIndex(6)
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.ads = AdvancedDockArea(gui_id="ads")
|
||||
seventh_tab_layout.addWidget(self.ads)
|
||||
tab_widget.addTab(seventh_tab, "ADS")
|
||||
tab_widget.setCurrentIndex(2)
|
||||
#
|
||||
# eighth_tab = QWidget()
|
||||
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
@@ -169,6 +170,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
|
||||
@@ -77,6 +77,8 @@ class BECConnector:
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
widget_removed = Signal()
|
||||
name_established = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -161,8 +163,6 @@ 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()
|
||||
@@ -186,24 +186,6 @@ 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.
|
||||
@@ -224,6 +206,10 @@ class BECConnector:
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
@@ -233,7 +219,7 @@ class BECConnector:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.processEvents()
|
||||
QApplication.sendPostedEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
@@ -470,6 +456,7 @@ class BECConnector:
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
|
||||
@@ -38,9 +38,11 @@ def _loaded_submodules_from_specs(
|
||||
try:
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(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}")
|
||||
yield submodule
|
||||
|
||||
|
||||
@@ -59,7 +61,8 @@ 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 item is not BECWidget
|
||||
and not item.__module__.startswith("bec_widgets"),
|
||||
)
|
||||
return BECClassContainer(
|
||||
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
|
||||
|
||||
0
bec_widgets/utils/bec_plugin_manager/__init__.py
Normal file
0
bec_widgets/utils/bec_plugin_manager/__init__.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
@@ -0,0 +1,136 @@
|
||||
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...")
|
||||
@@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
import PySide6QtAds as QtAds
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
@@ -23,7 +27,7 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
USER_ACCESS = ["remove", "attach", "detach"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
@@ -32,6 +36,8 @@ class BECWidget(BECConnector):
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
busy_text: str = "Loading…",
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
**kwargs,
|
||||
):
|
||||
@@ -41,8 +47,7 @@ class BECWidget(BECConnector):
|
||||
|
||||
>>> class MyWidget(BECWidget, QWidget):
|
||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
>>> super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
>>> QWidget.__init__(self, parent=parent)
|
||||
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||
|
||||
|
||||
Args:
|
||||
@@ -58,25 +63,32 @@ class BECWidget(BECConnector):
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
# Instead, we will set the theme to the system setting on startup
|
||||
if darkdetect.isDark():
|
||||
set_theme("dark")
|
||||
else:
|
||||
set_theme("light")
|
||||
|
||||
if theme_update:
|
||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||
self._connect_to_theme_change()
|
||||
|
||||
# Initialize optional busy loader overlay utility (lazy by default)
|
||||
self._busy_overlay = None
|
||||
self._loading = False
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
try:
|
||||
overlay = self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if overlay is not None:
|
||||
overlay.setGeometry(self.rect())
|
||||
overlay.raise_()
|
||||
overlay.show()
|
||||
self._loading = True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Busy loader init skipped: {exc}")
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
if hasattr(qapp, "theme"):
|
||||
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
@SafeSlot(str)
|
||||
@SafeSlot()
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -85,9 +97,78 @@ class BECWidget(BECConnector):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
@Slot(str)
|
||||
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
|
||||
"""Create the busy overlay on demand and cache it in _busy_overlay.
|
||||
Returns the overlay instance or None if not a QWidget.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return None
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
|
||||
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
return overlay
|
||||
|
||||
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
self._ensure_busy_overlay(busy_text=busy_text)
|
||||
if start_busy and self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool, text: str | None = None) -> None:
|
||||
"""
|
||||
Enable/disable the loading overlay. Optionally update the text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the loading overlay.
|
||||
text(str, optional): The text to display on the overlay. If None, the text is not changed.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
if getattr(self, "_busy_overlay", None) is None:
|
||||
self._ensure_busy_overlay(busy_text=text or "Loading…")
|
||||
if text is not None:
|
||||
self.set_busy_text(text)
|
||||
if enabled:
|
||||
self._busy_overlay.setGeometry(self.rect())
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
else:
|
||||
self._busy_overlay.hide()
|
||||
self._loading = bool(enabled)
|
||||
|
||||
def is_busy(self) -> bool:
|
||||
"""
|
||||
Check if the loading overlay is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if the loading overlay is enabled, False otherwise.
|
||||
"""
|
||||
return bool(getattr(self, "_loading", False))
|
||||
|
||||
def set_busy_text(self, text: str) -> None:
|
||||
"""
|
||||
Update the text on the loading overlay.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is None:
|
||||
overlay = self._ensure_busy_overlay(busy_text=text)
|
||||
if overlay is not None:
|
||||
overlay.set_text(text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the widget.
|
||||
@@ -96,12 +177,95 @@ class BECWidget(BECConnector):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
|
||||
def _update_overlay_theme(self, theme: str):
|
||||
try:
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and hasattr(overlay, "update_palette"):
|
||||
overlay.update_palette()
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
def get_help_md(self) -> str:
|
||||
"""
|
||||
Method to override in subclasses to provide help text in markdown format.
|
||||
|
||||
Returns:
|
||||
str: The help text in markdown format.
|
||||
"""
|
||||
|
||||
@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 attach(self):
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
|
||||
if not dock.isFloating():
|
||||
return
|
||||
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||
if dock is None:
|
||||
return
|
||||
if dock.isFloating():
|
||||
return
|
||||
dock.setFloating()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# 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()
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
if overlay is not None and shiboken6.isValid(overlay):
|
||||
try:
|
||||
overlay.hide()
|
||||
filt = getattr(overlay, "_filter", None)
|
||||
if filt is not None and shiboken6.isValid(filt):
|
||||
try:
|
||||
self.removeEventFilter(filt)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
self._busy_overlay = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
|
||||
253
bec_widgets/utils/busy_loader.py
Normal file
253
bec_widgets/utils/busy_loader.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
|
||||
class _OverlayEventFilter(QObject):
|
||||
"""Keeps the overlay sized and stacked over its target widget."""
|
||||
|
||||
def __init__(self, target: QWidget, overlay: QWidget):
|
||||
super().__init__(target)
|
||||
self._target = target
|
||||
self._overlay = overlay
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self._target and event.type() in (
|
||||
QEvent.Resize,
|
||||
QEvent.Show,
|
||||
QEvent.LayoutRequest,
|
||||
QEvent.Move,
|
||||
):
|
||||
self._overlay.setGeometry(self._target.rect())
|
||||
self._overlay.raise_()
|
||||
return False
|
||||
|
||||
|
||||
class BusyLoaderOverlay(QWidget):
|
||||
"""
|
||||
A semi-transparent scrim with centered text and an animated spinner.
|
||||
Call show()/hide() directly, or use via `install_busy_loader(...)`.
|
||||
|
||||
Args:
|
||||
parent(QWidget): The parent widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setAutoFillBackground(False)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
self._opacity = opacity
|
||||
|
||||
self._label = QLabel(text, self)
|
||||
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(self._label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
self._label.setFont(f)
|
||||
|
||||
self._spinner = SpinnerWidget(self)
|
||||
self._spinner.setFixedSize(42, 42)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(self._label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._scrim_color = QColor(0, 0, 0, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self.update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
# --- API ---
|
||||
def set_text(self, text: str):
|
||||
"""
|
||||
Update the overlay text.
|
||||
|
||||
Args:
|
||||
text(str): The text to display on the overlay.
|
||||
"""
|
||||
self._label.setText(text)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set overlay opacity (0..1).
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
|
||||
"""
|
||||
self._opacity = max(0.0, min(1.0, float(opacity)))
|
||||
# Re-apply alpha using the current theme color
|
||||
if isinstance(self._scrim_color, QColor):
|
||||
base = QColor(self._scrim_color)
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self._scrim_color = base
|
||||
self.update()
|
||||
|
||||
def update_palette(self):
|
||||
"""
|
||||
Update colors from the current application theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme # type: ignore[attr-defined]
|
||||
self._bg = theme.color("BORDER")
|
||||
self._fg = theme.color("FG")
|
||||
self._primary = theme.color("PRIMARY")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
self._bg = QColor(30, 30, 30)
|
||||
self._fg = QColor(230, 230, 230)
|
||||
# Semi-transparent scrim derived from bg
|
||||
self._scrim_color = QColor(self._bg)
|
||||
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self._spinner.update()
|
||||
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
|
||||
self._label.setStyleSheet(f"color: {fg_hex};")
|
||||
self._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
# --- QWidget overrides ---
|
||||
def showEvent(self, e):
|
||||
self._spinner.start()
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
self._spinner.stop()
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
def paintEvent(self, e):
|
||||
super().paintEvent(e)
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
|
||||
) -> BusyLoaderOverlay:
|
||||
"""
|
||||
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
|
||||
|
||||
Args:
|
||||
target(QWidget): The widget to overlay.
|
||||
text(str): Initial text to display.
|
||||
start_loading(bool): If True, show the overlay immediately.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
filt = _OverlayEventFilter(target, overlay)
|
||||
overlay._filter = filt # type: ignore[attr-defined]
|
||||
target.installEventFilter(filt)
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
|
||||
|
||||
# --------------------------
|
||||
# Launchable demo
|
||||
# --------------------------
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
|
||||
)
|
||||
|
||||
self._title = QLabel("Demo Content", self)
|
||||
self._title.setAlignment(Qt.AlignCenter)
|
||||
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addWidget(self._title)
|
||||
waveform = Waveform(self)
|
||||
waveform.plot([1, 2, 3, 4, 5])
|
||||
lay.addWidget(waveform, 1)
|
||||
|
||||
QTimer.singleShot(5000, self._ready)
|
||||
|
||||
def _ready(self):
|
||||
self._title.setText("Ready ✓")
|
||||
self.set_busy(False)
|
||||
|
||||
|
||||
class DemoWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget()
|
||||
right = DemoWidget()
|
||||
|
||||
btn_on = QPushButton("Right → Loading")
|
||||
btn_off = QPushButton("Right → Ready")
|
||||
btn_text = QPushButton("Set custom text")
|
||||
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
|
||||
|
||||
panel = QWidget()
|
||||
prow = QVBoxLayout(panel)
|
||||
prow.addWidget(btn_on)
|
||||
prow.addWidget(btn_off)
|
||||
prow.addWidget(btn_text)
|
||||
prow.addStretch(1)
|
||||
|
||||
central = QWidget()
|
||||
row = QHBoxLayout(central)
|
||||
row.setContentsMargins(12, 12, 12, 12)
|
||||
row.setSpacing(12)
|
||||
row.addWidget(left, 1)
|
||||
row.addWidget(right, 1)
|
||||
row.addWidget(panel, 0)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
self.resize(900, 420)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
w = DemoWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,19 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import Literal
|
||||
|
||||
import bec_qthemes
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from bec_qthemes._theme import AccentColors
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
@@ -23,118 +21,35 @@ def get_theme_name():
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
return bec_qthemes.load_palette(get_theme_name())
|
||||
# FIXME this is legacy code, should be removed in the future
|
||||
app = QApplication.instance()
|
||||
palette = app.palette()
|
||||
return palette
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
def get_accent_colors() -> AccentColors:
|
||||
"""
|
||||
Get the accent colors for the current theme. These colors are extensions of the color palette
|
||||
and are used to highlight specific elements in the UI.
|
||||
"""
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return None
|
||||
accent_colors = AccentColors()
|
||||
return accent_colors
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
def _theme_update_callback():
|
||||
"""
|
||||
Internal callback function to update the theme based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
# pylint: disable=protected-access
|
||||
app.theme.theme = app.os_listener._theme.lower()
|
||||
app.theme_signal.theme_updated.emit(app.theme.theme)
|
||||
apply_theme(app.os_listener._theme.lower())
|
||||
|
||||
|
||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||
"""
|
||||
Set the theme for the application.
|
||||
|
||||
Args:
|
||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
||||
|
||||
app.theme_signal.theme_updated.emit(theme)
|
||||
apply_theme(theme)
|
||||
|
||||
if theme != "auto":
|
||||
return
|
||||
|
||||
if not hasattr(app, "os_listener") or app.os_listener is None:
|
||||
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
||||
app.installEventFilter(app.os_listener)
|
||||
def process_all_deferred_deletes(qapp):
|
||||
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
qapp.processEvents(QEventLoop.AllEvents)
|
||||
|
||||
|
||||
def apply_theme(theme: Literal["dark", "light"]):
|
||||
"""
|
||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
||||
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
graphic_layouts = [
|
||||
child
|
||||
for top in app.topLevelWidgets()
|
||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
||||
]
|
||||
|
||||
plot_items = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.PlotItem)
|
||||
]
|
||||
|
||||
histograms = [
|
||||
item
|
||||
for gl in graphic_layouts
|
||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
||||
if isinstance(item, pg.HistogramLUTItem)
|
||||
]
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
foreground_color = "#141414"
|
||||
label_color = "#000000"
|
||||
axis_color = "#666666"
|
||||
else:
|
||||
background_color = "#141414" # Dark mode
|
||||
foreground_color = "#e9ecef"
|
||||
label_color = "#FFFFFF"
|
||||
axis_color = "#CCCCCC"
|
||||
|
||||
# update GraphicsLayoutWidget
|
||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
||||
for pg_widget in graphic_layouts:
|
||||
pg_widget.setBackground(background_color)
|
||||
|
||||
# update PlotItems
|
||||
for plot_item in plot_items:
|
||||
for axis in ["left", "right", "top", "bottom"]:
|
||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# Change title color
|
||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
||||
|
||||
# Change legend color
|
||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
||||
plot_item.legend.setLabelTextColor(label_color)
|
||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
||||
for sample, label in plot_item.legend.items:
|
||||
label_text = label.text
|
||||
label.setText(label_text, color=label_color)
|
||||
|
||||
# update HistogramLUTItem
|
||||
for histogram in histograms:
|
||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
||||
|
||||
# now define stylesheet according to theme and apply it
|
||||
style = bec_qthemes.load_stylesheet(theme)
|
||||
app.setStyleSheet(style)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
apply_theme_global(theme)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@@ -11,6 +11,7 @@ from qtpy.QtWidgets import (
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -122,15 +123,14 @@ class CompactPopupWidget(QWidget):
|
||||
self.compact_view_widget = QWidget(self)
|
||||
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
QHBoxLayout(self.compact_view_widget)
|
||||
self.compact_view_widget.layout().setSpacing(0)
|
||||
self.compact_view_widget.layout().setSpacing(5)
|
||||
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.compact_view_widget.layout().addSpacerItem(
|
||||
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
)
|
||||
self.compact_label = QLabel(self.compact_view_widget)
|
||||
self.compact_status = LedLabel(self.compact_view_widget)
|
||||
self.compact_show_popup = QPushButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setFlat(True)
|
||||
self.compact_show_popup = QToolButton(self.compact_view_widget)
|
||||
self.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
@@ -259,12 +259,3 @@ 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)
|
||||
|
||||
@@ -209,8 +209,11 @@ class Crosshair(QObject):
|
||||
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
|
||||
# Focus on the highlighted curve only
|
||||
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
|
||||
else:
|
||||
# Handle all curves
|
||||
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
|
||||
# Handle visible items in the plot item
|
||||
self.items = self.plot_item.visible_items()
|
||||
else: # Non PlotBase case
|
||||
# Handle all items
|
||||
self.items = self.plot_item.items
|
||||
|
||||
# Create or update markers
|
||||
|
||||
@@ -28,6 +28,10 @@ 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:
|
||||
|
||||
@@ -2,7 +2,9 @@ import functools
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
@@ -90,6 +92,52 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
||||
return decorator
|
||||
|
||||
|
||||
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
|
||||
"""Internal function used by SafeConnect to handle weak references to slots."""
|
||||
instance = weak_instance()
|
||||
slot_func = weak_slot()
|
||||
|
||||
# Check if the python object has already been garbage collected
|
||||
if instance is None or slot_func is None:
|
||||
return
|
||||
|
||||
# Check if the python object has already been marked for deletion
|
||||
if getattr(instance, "_destroyed", False):
|
||||
return
|
||||
|
||||
# Check if the C++ object is still valid
|
||||
if not shiboken6.isValid(instance):
|
||||
return
|
||||
|
||||
if connect_args:
|
||||
slot_func(*connect_args)
|
||||
slot_func()
|
||||
|
||||
|
||||
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to safely handle Qt signal-slot connections. The python object is only forwarded
|
||||
as a weak reference to avoid stale objects.
|
||||
|
||||
Args:
|
||||
instance: The instance to connect.
|
||||
signal: The signal to connect to.
|
||||
slot: The slot to connect.
|
||||
|
||||
Example:
|
||||
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||
|
||||
"""
|
||||
weak_instance = safe_ref(instance)
|
||||
weak_slot = safe_ref(slot)
|
||||
|
||||
# Create a partial function that will check weak references before calling the actual slot
|
||||
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
|
||||
|
||||
# Connect the signal to the safe connect slot wrapper
|
||||
return signal.connect(safe_slot)
|
||||
|
||||
|
||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||
to the passed function, to display errors instead of potentially raising an exception
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -19,7 +19,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
expansion_state_changed = Signal()
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
@@ -31,10 +32,11 @@ class ExpandableGroupFrame(QFrame):
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self._title_text = f"<b>{title}</b>"
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setContentsMargins(5, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
@@ -49,21 +51,27 @@ class ExpandableGroupFrame(QFrame):
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._internal_title_layout = QHBoxLayout()
|
||||
self._title_layout.addLayout(self._internal_title_layout)
|
||||
|
||||
self._title = ClickableLabel(f"<b>{title}</b>")
|
||||
self._title = ClickableLabel()
|
||||
self._set_title_text(self._title_text)
|
||||
self._title_icon = ClickableLabel()
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self._internal_title_layout.addWidget(self._title_icon)
|
||||
self._internal_title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
self._internal_title_layout.addStretch(1)
|
||||
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
def get_title_layout(self) -> QHBoxLayout:
|
||||
return self._internal_title_layout
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
@@ -112,6 +120,18 @@ class ExpandableGroupFrame(QFrame):
|
||||
else:
|
||||
self._title_icon.setVisible(False)
|
||||
|
||||
@SafeProperty(str)
|
||||
def title_text(self): # type: ignore
|
||||
return self._title_text
|
||||
|
||||
@title_text.setter
|
||||
def title_text(self, title_text: str):
|
||||
self._title_text = title_text
|
||||
self._set_title_text(self._title_text)
|
||||
|
||||
def _set_title_text(self, title_text: str):
|
||||
self._title.setText(title_text)
|
||||
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import NoneType
|
||||
from types import GenericAlias, NoneType, UnionType
|
||||
from typing import NamedTuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBox
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
@@ -171,8 +171,9 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
form_data_updated = Signal(dict)
|
||||
form_data_cleared = Signal(NoneType)
|
||||
validity_proc = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -204,7 +205,7 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Metadata validity" # type: ignore
|
||||
self._validity.label = "Validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
@@ -215,6 +216,9 @@ class PydanticModelForm(TypedForm):
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self): ...
|
||||
|
||||
def set_pretty_display_theme(self, theme: str = "dark"):
|
||||
if self._pretty_display:
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
@@ -264,16 +268,39 @@ 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 metadata_cleared and returns false."""
|
||||
Otherwise, emits on form_data_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.metadata_updated.emit(metadata_dict)
|
||||
self.form_data_updated.emit(metadata_dict)
|
||||
self.validity_proc.emit(True)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
self.form_data_cleared.emit(None)
|
||||
self.validity_proc.emit(False)
|
||||
return False
|
||||
|
||||
|
||||
class PydanticModelFormItem(DynamicFormItem):
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
|
||||
) -> None:
|
||||
self._data_model = model
|
||||
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.form_data_updated.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
|
||||
self._main_widget = PydanticModelForm(data_model=self._data_model)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.get_form_data()
|
||||
|
||||
def setValue(self, value: dict):
|
||||
self._main_widget.set_data(self._data_model.model_validate(value))
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
from typing import (
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
OrderedDict,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
get_args,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -158,9 +171,10 @@ class DynamicFormItem(QWidget):
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
# Sadly, QWidget and ABC are not compatible
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
@@ -175,6 +189,7 @@ class DynamicFormItem(QWidget):
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget: QWidget
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
@@ -350,11 +365,13 @@ class DictFormItem(DynamicFormItem):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple):
|
||||
# TODO: this should be generic but not supported in 3.10
|
||||
item: type[int | float | str]
|
||||
_IW = TypeVar("_IW", bound=int | float | str)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
|
||||
item: type[_IW]
|
||||
widget: type[QWidget]
|
||||
default: int | float | str
|
||||
default: _IW
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
@@ -380,7 +397,7 @@ class ListFormItem(DynamicFormItem):
|
||||
|
||||
def sizeHint(self):
|
||||
default = super().sizeHint()
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 4)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QListWidget()
|
||||
@@ -390,17 +407,29 @@ 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_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
|
||||
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)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
super()._set_pretty_display()
|
||||
@@ -418,10 +447,17 @@ class ListFormItem(DynamicFormItem):
|
||||
self._add_list_item(val)
|
||||
self._repop(self._data)
|
||||
|
||||
def _item_height(self):
|
||||
return int(QFontMetrics(self.font()).height() * 1.5)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item = QListWidgetItem(self._main_widget)
|
||||
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
|
||||
item_widget = self._types.widget(parent=self)
|
||||
item_widget.setMinimumHeight(self._item_height())
|
||||
self._main_widget.setGridSize(QSize(0, self._item_height()))
|
||||
if (layout := item_widget.layout()) is not None:
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
WidgetIO.set_value(item_widget, val)
|
||||
self._main_widget.setItemWidget(item, item_widget)
|
||||
self._main_widget.addItem(item)
|
||||
@@ -458,14 +494,11 @@ class ListFormItem(DynamicFormItem):
|
||||
self._data = list(value)
|
||||
self._repop(self._data)
|
||||
|
||||
def _line_height(self):
|
||||
return QFontMetrics(self._main_widget.font()).height()
|
||||
|
||||
def set_max_height_in_lines(self, lines: int):
|
||||
outer_inc = 1 if self._spec.pretty_display else 3
|
||||
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
|
||||
def scale_to_data(self, *_):
|
||||
self.set_max_height_in_lines(self._main_widget.count() + 1)
|
||||
@@ -533,7 +566,14 @@ class StrLiteralFormItem(DynamicFormItem):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
@runtime_checkable
|
||||
class _ItemTypeFn(Protocol):
|
||||
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[
|
||||
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
|
||||
]
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
@@ -574,7 +614,10 @@ def widget_from_type(
|
||||
widget_types = widget_types or DEFAULT_WIDGET_TYPES
|
||||
for predicate, widget_type in widget_types.values():
|
||||
if predicate(spec):
|
||||
return widget_type
|
||||
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
|
||||
return widget_type
|
||||
return widget_type(spec)
|
||||
|
||||
logger.warning(
|
||||
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
|
||||
_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)
|
||||
|
||||
0
bec_widgets/utils/help_inspector/__init__.py
Normal file
0
bec_widgets/utils/help_inspector/__init__.py
Normal file
246
bec_widgets/utils/help_inspector/help_inspector.py
Normal file
246
bec_widgets/utils/help_inspector/help_inspector.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Module providing a simple help inspector tool for QtWidgets."""
|
||||
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import AccentColors, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class HelpInspector(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
A help inspector widget that allows to inspect other widgets in the application.
|
||||
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
|
||||
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
|
||||
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
|
||||
The inspector also allows to register custom callback that are called with the inspected widget
|
||||
as argument. This may be useful in the future to hook up more callbacks with custom signals.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): The parent widget of the help inspector.
|
||||
client: Optional client for BECWidget functionality.
|
||||
size (tuple[int, int]): Optional size of the icon for the help inspector.
|
||||
"""
|
||||
|
||||
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
|
||||
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
|
||||
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
layout = QtWidgets.QHBoxLayout(self) # type: ignore
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._active = False
|
||||
self._init_ui()
|
||||
self._callbacks = {}
|
||||
# Register the default callbacks
|
||||
self._register_default_callbacks()
|
||||
# Connect the button toggle signal
|
||||
self._button.toggled.connect(self._toggle_mode)
|
||||
|
||||
def _init_ui(self):
|
||||
"""Init the UI components."""
|
||||
colors: AccentColors = get_accent_colors()
|
||||
self._button = QtWidgets.QToolButton(self.parent())
|
||||
self._button.setCheckable(True)
|
||||
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
self._button.setText("Help Inspect Tool")
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setToolTip("Click to enter Help Mode")
|
||||
self.layout().addWidget(self._button)
|
||||
|
||||
def apply_theme(self, theme: str) -> None:
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
if self._active:
|
||||
self._button.setIcon(self._icon_checked())
|
||||
else:
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _toggle_mode(self, enabled: bool):
|
||||
"""
|
||||
Toggle the help inspection mode.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether to enable or disable the help inspection mode.
|
||||
"""
|
||||
if self._app is None:
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
self._active = enabled
|
||||
if enabled:
|
||||
self._app.installEventFilter(self)
|
||||
self._button.setIcon(self._icon_checked())
|
||||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
|
||||
else:
|
||||
self._app.removeEventFilter(self)
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setChecked(False)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
|
||||
"""
|
||||
Filter events to capture Key_Escape event, and mouse clicks
|
||||
if event filter is active. Any click event on a widget is suppressed, if
|
||||
the Inspector is active, and the registered callbacks are called with
|
||||
the clicked widget as argument.
|
||||
|
||||
Args:
|
||||
obj (QObject): The object that received the event.
|
||||
event (QEvent): The event to filter.
|
||||
"""
|
||||
# If not active, return immediately
|
||||
if not self._active:
|
||||
return super().eventFilter(obj, event)
|
||||
# If active, handle escape key
|
||||
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
|
||||
self._toggle_mode(False)
|
||||
return super().eventFilter(obj, event)
|
||||
# If active, and left mouse button pressed, handle click
|
||||
if event.type() == QtCore.QEvent.MouseButtonPress:
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
widget = self._app.widgetAt(event.globalPos())
|
||||
if widget is None:
|
||||
return super().eventFilter(obj, event)
|
||||
# Get BECWidget ancestor
|
||||
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
|
||||
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
|
||||
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if widget:
|
||||
if widget is self:
|
||||
self._toggle_mode(False)
|
||||
return True
|
||||
for cb in self._callbacks.values():
|
||||
try:
|
||||
cb(widget)
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred in callback {cb}: {e}")
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
|
||||
"""
|
||||
Register a callback to be called when a widget is inspected.
|
||||
The callback should be callable with the following signature:
|
||||
callback(widget: QWidget) -> None
|
||||
|
||||
Args:
|
||||
callback (Callable[[QWidget], None]): The callback function to register.
|
||||
Returns:
|
||||
str: A unique ID for the registered callback.
|
||||
"""
|
||||
cb_id = str(uuid4())
|
||||
self._callbacks[cb_id] = callback
|
||||
return cb_id
|
||||
|
||||
def unregister_callback(self, cb_id: str):
|
||||
"""Unregister a previously registered callback."""
|
||||
self._callbacks.pop(cb_id, None)
|
||||
|
||||
def _register_default_callbacks(self):
|
||||
"""Default behavior: publish tooltip, docstring, bec_help"""
|
||||
|
||||
def cb_doc(widget: QtWidgets.QWidget):
|
||||
docstring = widget.__doc__ or "No documentation available."
|
||||
self.widget_docstring.emit(docstring)
|
||||
|
||||
def cb_help(widget: QtWidgets.QWidget):
|
||||
tooltip = widget.toolTip() or "No tooltip available."
|
||||
self.widget_tooltip.emit(tooltip)
|
||||
|
||||
def cb_bec_help(widget: QtWidgets.QWidget):
|
||||
help_text = None
|
||||
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
|
||||
try:
|
||||
help_text = widget.get_help_md()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error retrieving help text from {widget}: {e}")
|
||||
if help_text is None:
|
||||
help_text = widget.toolTip() or "No help available."
|
||||
if not isinstance(help_text, str):
|
||||
logger.error(
|
||||
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
|
||||
)
|
||||
help_text = str(help_text)
|
||||
self.bec_widget_help.emit(help_text)
|
||||
|
||||
self.register_callback(cb_doc)
|
||||
self.register_callback(cb_help)
|
||||
self.register_callback(cb_bec_help)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
apply_theme("dark")
|
||||
main_window.setWindowTitle("Help Inspector Test")
|
||||
|
||||
central_widget = QtWidgets.QWidget()
|
||||
main_layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
dark_mode_button = DarkModeButton(parent=main_window)
|
||||
main_layout.addWidget(dark_mode_button)
|
||||
|
||||
help_inspector = HelpInspector()
|
||||
main_layout.addWidget(help_inspector)
|
||||
|
||||
test_button = QtWidgets.QPushButton("Test Button")
|
||||
test_button.setToolTip("This is a test button.")
|
||||
test_line_edit = QtWidgets.QLineEdit()
|
||||
test_line_edit.setToolTip("This is a test line edit.")
|
||||
test_label = QtWidgets.QLabel("Test Label")
|
||||
test_label.setToolTip("")
|
||||
box = PositionerBox()
|
||||
|
||||
layout_1 = QtWidgets.QHBoxLayout()
|
||||
layout_1.addWidget(test_button)
|
||||
layout_1.addWidget(test_line_edit)
|
||||
layout_1.addWidget(test_label)
|
||||
layout_1.addWidget(box)
|
||||
main_layout.addLayout(layout_1)
|
||||
|
||||
doc_label = QtWidgets.QLabel("Docstring will appear here.")
|
||||
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
|
||||
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
|
||||
main_layout.addWidget(doc_label)
|
||||
main_layout.addWidget(tool_tip_label)
|
||||
main_layout.addWidget(bec_help_label)
|
||||
|
||||
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
|
||||
help_inspector.widget_docstring.connect(doc_label.setText)
|
||||
help_inspector.bec_widget_help.connect(bec_help_label.setText)
|
||||
|
||||
main_window.setCentralWidget(central_widget)
|
||||
main_window.resize(400, 200)
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
133
bec_widgets/utils/list_of_expandable_frames.py
Normal file
133
bec_widgets/utils/list_of_expandable_frames.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import re
|
||||
from functools import partial
|
||||
from re import Pattern
|
||||
from typing import Generic, Iterable, NamedTuple, TypeVar
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.control.device_manager.components._util import (
|
||||
SORT_KEY_ROLE,
|
||||
SortableQListWidgetItem,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
|
||||
|
||||
|
||||
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
|
||||
def __init__(
|
||||
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
|
||||
self.item_tuple = _Items
|
||||
self._item_class = item_class
|
||||
self._item_dict: dict[str, _Items] = {}
|
||||
|
||||
def __contains__(self, id: str):
|
||||
return id in self._item_dict
|
||||
|
||||
def clear(self) -> None:
|
||||
self._item_dict = {}
|
||||
return super().clear()
|
||||
|
||||
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
|
||||
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
|
||||
|
||||
Args:
|
||||
id (str): the key under which to store the list item in the internal dict
|
||||
|
||||
Returns:
|
||||
The widget created in the addition process
|
||||
"""
|
||||
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.takeItem(self.row(item))
|
||||
del self._item_dict[id]
|
||||
self.sortItems()
|
||||
|
||||
def _updatesize(item: QListWidgetItem, item_widget: _EF):
|
||||
item_widget.adjustSize()
|
||||
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
|
||||
|
||||
item = SortableQListWidgetItem(self)
|
||||
item.setData(SORT_KEY_ROLE, id) # used for sorting
|
||||
|
||||
item_widget = self._item_class(*args, **kwargs)
|
||||
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
|
||||
item_widget.imminent_deletion.connect(partial(_remove_item, item))
|
||||
item_widget.broadcast_size_hint.connect(item.setSizeHint)
|
||||
|
||||
self.addItem(item)
|
||||
self.setItemWidget(item, item_widget)
|
||||
self._item_dict[id] = self.item_tuple(item, item_widget)
|
||||
|
||||
item.setSizeHint(item_widget.sizeHint())
|
||||
return (item, item_widget)
|
||||
|
||||
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
|
||||
items = [self.takeItem(0) for i in range(self.count())]
|
||||
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
|
||||
|
||||
for it in items:
|
||||
self.addItem(it)
|
||||
# reattach its custom widget
|
||||
widget = self.itemWidget(it)
|
||||
if widget:
|
||||
self.setItemWidget(it, widget)
|
||||
|
||||
def item_widget_pairs(self):
|
||||
return self._item_dict.values()
|
||||
|
||||
def widgets(self):
|
||||
return (i.widget for i in self._item_dict.values())
|
||||
|
||||
def get_item_widget(self, id: str):
|
||||
if (item := self._item_dict.get(id)) is None:
|
||||
return None
|
||||
return item
|
||||
|
||||
def set_hidden_pattern(self, pattern: Pattern):
|
||||
self.hide_all()
|
||||
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
|
||||
|
||||
def set_hidden(self, ids: Iterable[str]):
|
||||
self._set_hidden(ids, True)
|
||||
|
||||
def _set_hidden(self, ids: Iterable[str], hidden: bool):
|
||||
for id in ids:
|
||||
if (_item := self._item_dict.get(id)) is not None:
|
||||
_item.item.setHidden(hidden)
|
||||
_item.widget.setHidden(hidden)
|
||||
else:
|
||||
logger.warning(
|
||||
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
|
||||
)
|
||||
self.sortItems()
|
||||
|
||||
def hide_all(self):
|
||||
self.set_hidden_state_on_all(True)
|
||||
|
||||
def unhide_all(self):
|
||||
self.set_hidden_state_on_all(False)
|
||||
|
||||
def set_hidden_state_on_all(self, hidden: bool):
|
||||
for _item in self._item_dict.values():
|
||||
_item.item.setHidden(hidden)
|
||||
_item.widget.setHidden(hidden)
|
||||
self.sortItems()
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_filter(self, value: str):
|
||||
if value == "":
|
||||
return self.unhide_all()
|
||||
try:
|
||||
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
|
||||
except Exception:
|
||||
self.unhide_all()
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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}
|
||||
@@ -20,6 +21,8 @@ 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
|
||||
|
||||
|
||||
@@ -201,7 +201,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, BECWidget):
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
|
||||
694
bec_widgets/utils/property_editor.py
Normal file
694
bec_widgets/utils/property_editor.py
Normal file
@@ -0,0 +1,694 @@
|
||||
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())
|
||||
@@ -1,11 +1,12 @@
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property
|
||||
from qtpy.QtCore import Property, Qt
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class RoundedFrame(QFrame):
|
||||
# TODO this should be removed completely in favor of QSS styling, no time now
|
||||
"""
|
||||
A custom QFrame with rounded corners and optional theme updates.
|
||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||
@@ -28,6 +29,9 @@ class RoundedFrame(QFrame):
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("roundedFrame")
|
||||
|
||||
# Ensure QSS can paint background/border on this widget
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
# Create a layout for the frame
|
||||
if orientation == "vertical":
|
||||
self.layout = QVBoxLayout(self)
|
||||
@@ -45,22 +49,10 @@ class RoundedFrame(QFrame):
|
||||
|
||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||
self.apply_plot_widget_style()
|
||||
self.update_style()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the frame and its content if theme updates are enabled.
|
||||
"""
|
||||
if self.content_widget is not None and isinstance(
|
||||
self.content_widget, pg.GraphicsLayoutWidget
|
||||
):
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
self.background_color = "#e9ecef" # Subtle contrast for light mode
|
||||
else:
|
||||
self.background_color = "#141414" # Dark mode
|
||||
|
||||
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
|
||||
self.update_style()
|
||||
|
||||
@Property(int)
|
||||
@@ -77,34 +69,21 @@ class RoundedFrame(QFrame):
|
||||
"""
|
||||
Update the style of the frame based on the background color.
|
||||
"""
|
||||
if self.background_color:
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
QFrame#roundedFrame {{
|
||||
background-color: {self.background_color};
|
||||
border-radius: {self._radius}; /* Rounded corners */
|
||||
border-radius: {self._radius}px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
)
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_plot_widget_style(self, border: str = "none"):
|
||||
"""
|
||||
Automatically apply background, border, and axis styles to the PlotWidget.
|
||||
|
||||
Args:
|
||||
border (str): Border style (e.g., 'none', '1px solid red').
|
||||
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
|
||||
"""
|
||||
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
||||
# Apply border style via stylesheet
|
||||
self.content_widget.setStyleSheet(
|
||||
f"""
|
||||
GraphicsLayoutWidget {{
|
||||
border: {border}; /* Explicitly set the border */
|
||||
}}
|
||||
"""
|
||||
)
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
self.content_widget.setStyleSheet("")
|
||||
|
||||
|
||||
class ExampleApp(QWidget): # pragma: no cover
|
||||
@@ -128,24 +107,14 @@ class ExampleApp(QWidget): # pragma: no cover
|
||||
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
||||
plot2.plot_item = plot_item_2
|
||||
|
||||
# Wrap PlotWidgets in RoundedFrame
|
||||
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
|
||||
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
|
||||
|
||||
# Add to layout
|
||||
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
|
||||
layout.addWidget(dark_button)
|
||||
layout.addWidget(rounded_plot1)
|
||||
layout.addWidget(rounded_plot2)
|
||||
layout.addWidget(plot1)
|
||||
layout.addWidget(plot2)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
def change_theme():
|
||||
rounded_plot1.apply_theme("light")
|
||||
rounded_plot2.apply_theme("dark")
|
||||
|
||||
QTimer.singleShot(100, change_theme)
|
||||
# Theme flip demo removed; global theming applies automatically
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -13,3 +13,17 @@ 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -229,6 +230,8 @@ class RPCServer:
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
logger.info(f"Waiting for {connector} to be registered...")
|
||||
time.sleep(0.1)
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from typing import Type
|
||||
|
||||
from bec_lib.codecs import BECCodec
|
||||
from bec_lib.serialization import msgpack
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
@@ -6,39 +9,26 @@ def register_serializer_extension():
|
||||
"""
|
||||
Register the serializer extension for the BECConnector.
|
||||
"""
|
||||
if not module_is_registered("bec_widgets.utils.serialization"):
|
||||
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
|
||||
if not msgpack.is_registered(QPointF):
|
||||
msgpack.register_codec(QPointFEncoder)
|
||||
|
||||
|
||||
def module_is_registered(module_name: str) -> bool:
|
||||
"""
|
||||
Check if the module is registered in the encoder.
|
||||
class QPointFEncoder(BECCodec):
|
||||
obj_type: Type = QPointF
|
||||
|
||||
Args:
|
||||
module_name (str): The name of the module to check.
|
||||
@staticmethod
|
||||
def encode(obj: QPointF) -> str:
|
||||
"""
|
||||
Encode a QPointF object to a list of floats. As this is mostly used for sending
|
||||
data to the client, it is not necessary to convert it back to a QPointF object.
|
||||
"""
|
||||
if isinstance(obj, QPointF):
|
||||
return [obj.x(), obj.y()]
|
||||
return obj
|
||||
|
||||
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
|
||||
@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
|
||||
|
||||
@@ -33,6 +33,26 @@ logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
"""
|
||||
Helper function to create a toolbar button with text beside or under the icon.
|
||||
|
||||
Args:
|
||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||
"""
|
||||
|
||||
btn = QToolButton(parent=toolbar)
|
||||
btn.setDefaultAction(toolbar_action.action)
|
||||
btn.setAutoRaise(True)
|
||||
if toolbar_action.text_position == "under":
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
|
||||
else:
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
btn.setText(toolbar_action.label_text)
|
||||
toolbar.addWidget(btn)
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
@@ -114,15 +134,39 @@ class SeparatorAction(ToolBarAction):
|
||||
|
||||
|
||||
class QtIconAction(ToolBarAction):
|
||||
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
standard_icon,
|
||||
tooltip=None,
|
||||
checkable=False,
|
||||
label_text: str | None = None,
|
||||
text_position: Literal["beside", "under"] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
Action with a standard Qt icon for the toolbar.
|
||||
|
||||
Args:
|
||||
standard_icon: The standard icon from QStyle.
|
||||
tooltip(str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable(bool, optional): Whether the action is checkable. Defaults to False.
|
||||
label_text(str | None, optional): Optional label text to display beside or under the icon.
|
||||
text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon.
|
||||
parent(QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.standard_icon = standard_icon
|
||||
self.icon = QApplication.style().standardIcon(standard_icon)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
self.label_text = label_text
|
||||
self.text_position = text_position
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
toolbar.addAction(self.action)
|
||||
if self.label_text is not None:
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar)
|
||||
else:
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
return self.icon
|
||||
@@ -139,6 +183,8 @@ class MaterialIconAction(ToolBarAction):
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||
Defaults to None.
|
||||
label_text (str | None, optional): Optional label text to display beside or under the icon.
|
||||
text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
@@ -149,12 +195,20 @@ class MaterialIconAction(ToolBarAction):
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
label_text: str | None = None,
|
||||
text_position: Literal["beside", "under"] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon.
|
||||
This enables per-action icon text without breaking the existing API.
|
||||
"""
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
self.label_text = label_text
|
||||
self.text_position = text_position
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
@@ -178,7 +232,10 @@ class MaterialIconAction(ToolBarAction):
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
toolbar.addAction(self.action)
|
||||
if self.label_text is not None:
|
||||
create_action_with_text(toolbar_action=self, toolbar=toolbar)
|
||||
else:
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
@@ -446,6 +503,8 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
button = QToolButton(toolbar)
|
||||
button.setObjectName("toolbarMenuButton")
|
||||
button.setAutoRaise(True)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -42,11 +43,15 @@ 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(
|
||||
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
|
||||
self.set_fps_monitor
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
@@ -54,5 +59,6 @@ class PerformanceConnection(BundleConnection):
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
|
||||
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
|
||||
self.set_fps_monitor
|
||||
)
|
||||
self._connected = False
|
||||
|
||||
@@ -10,7 +10,7 @@ from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
@@ -507,7 +507,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
self.test_label.setText("FPS Monitor Disabled")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
apply_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -264,6 +264,48 @@ 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 ##################
|
||||
|
||||
@@ -423,13 +465,19 @@ class WidgetHierarchy:
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
parent = widget.parent()
|
||||
|
||||
# Retrieve first parent
|
||||
parent = widget.parent() if hasattr(widget, "parent") else None
|
||||
# Walk up, validating each step
|
||||
while parent is not None:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -511,6 +559,64 @@ class WidgetHierarchy:
|
||||
WidgetIO.set_value(child, value)
|
||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||
|
||||
@staticmethod
|
||||
def get_bec_connectors_from_parent(widget) -> list:
|
||||
"""
|
||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||
including the widget itself if it is a BECConnector.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
connectors: list[BECConnector] = []
|
||||
if isinstance(widget, BECConnector):
|
||||
connectors.append(widget)
|
||||
for child in widget.findChildren(BECConnector):
|
||||
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||
connectors.append(child)
|
||||
return connectors
|
||||
|
||||
@staticmethod
|
||||
def find_ancestor(widget, ancestor_class) -> QWidget | None:
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest ancestor matching ancestor_class.
|
||||
ancestor_class may be a class or a class-name string.
|
||||
Returns the matching ancestor, or None if none is found.
|
||||
"""
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
|
||||
# If searching for BECConnector specifically, reuse the dedicated helper
|
||||
try:
|
||||
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
||||
|
||||
if ancestor_class is BECConnector or (
|
||||
isinstance(ancestor_class, str) and ancestor_class == "BECConnector"
|
||||
):
|
||||
return WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
except Exception:
|
||||
# If import fails, fall back to generic traversal below
|
||||
pass
|
||||
|
||||
# Generic traversal across QObject parent chain
|
||||
parent = getattr(widget, "parent", None)
|
||||
if callable(parent):
|
||||
parent = parent()
|
||||
while parent is not None:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
try:
|
||||
if isinstance(ancestor_class, str):
|
||||
if parent.__class__.__name__ == ancestor_class:
|
||||
return parent
|
||||
else:
|
||||
if isinstance(parent, ancestor_class):
|
||||
return parent
|
||||
except Exception:
|
||||
pass
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
|
||||
# Example usage
|
||||
def hierarchy_example(): # pragma: no cover
|
||||
|
||||
@@ -15,6 +15,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -29,43 +31,58 @@ class WidgetStateManager:
|
||||
def __init__(self, widget):
|
||||
self.widget = widget
|
||||
|
||||
def save_state(self, filename: str = None):
|
||||
def save_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||
"""
|
||||
Save the state of the widget to an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to save the state to.
|
||||
settings(QSettings): Optional QSettings object to save the state to.
|
||||
"""
|
||||
if not filename:
|
||||
if not filename and not settings:
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
elif settings:
|
||||
# If settings are provided, save the state to the provided QSettings object
|
||||
self._save_widget_state_qsettings(self.widget, settings)
|
||||
else:
|
||||
logger.warning("No filename or settings provided for saving state.")
|
||||
|
||||
def load_state(self, filename: str = None):
|
||||
def load_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||
"""
|
||||
Load the state of the widget from an INI file.
|
||||
|
||||
Args:
|
||||
filename(str): The filename to load the state from.
|
||||
settings(QSettings): Optional QSettings object to load the state from.
|
||||
"""
|
||||
if not filename:
|
||||
if not filename and not settings:
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
elif settings:
|
||||
# If settings are provided, load the state from the provided QSettings object
|
||||
self._load_widget_state_qsettings(self.widget, settings)
|
||||
else:
|
||||
logger.warning("No filename or settings provided for saving state.")
|
||||
|
||||
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
def _save_widget_state_qsettings(
|
||||
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||
):
|
||||
"""
|
||||
Save the state of the widget to QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to save the state for.
|
||||
settings(QSettings): The QSettings object to save the state to.
|
||||
recursive(bool): Whether to recursively save the state of child widgets.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
@@ -88,21 +105,32 @@ class WidgetStateManager:
|
||||
settings.endGroup()
|
||||
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
if not recursive:
|
||||
return
|
||||
|
||||
direct_children = widget.children()
|
||||
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||
all_children = list(
|
||||
set(direct_children) | set(bec_connector_children)
|
||||
) # to avoid duplicates
|
||||
for child in all_children:
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._save_widget_state_qsettings(child, settings)
|
||||
self._save_widget_state_qsettings(child, settings, False)
|
||||
|
||||
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
||||
def _load_widget_state_qsettings(
|
||||
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||
):
|
||||
"""
|
||||
Load the state of the widget from QSettings.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to load the state for.
|
||||
settings(QSettings): The QSettings object to load the state from.
|
||||
recursive(bool): Whether to recursively load the state of child widgets.
|
||||
"""
|
||||
if widget.property("skip_settings") is True:
|
||||
return
|
||||
@@ -118,14 +146,21 @@ class WidgetStateManager:
|
||||
widget.setProperty(name, value)
|
||||
settings.endGroup()
|
||||
|
||||
if not recursive:
|
||||
return
|
||||
# Recursively process children (only if they aren't skipped)
|
||||
for child in widget.children():
|
||||
direct_children = widget.children()
|
||||
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||
all_children = list(
|
||||
set(direct_children) | set(bec_connector_children)
|
||||
) # to avoid duplicates
|
||||
for child in all_children:
|
||||
if (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
and not isinstance(child, QLabel)
|
||||
):
|
||||
self._load_widget_state_qsettings(child, settings)
|
||||
self._load_widget_state_qsettings(child, settings, False)
|
||||
|
||||
def _get_full_widget_name(self, widget: QWidget):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal, cast
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from shiboken6 import isValid
|
||||
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
SETTINGS_KEYS,
|
||||
is_profile_readonly,
|
||||
list_profiles,
|
||||
open_settings,
|
||||
profile_path,
|
||||
read_manifest,
|
||||
set_profile_readonly,
|
||||
write_manifest,
|
||||
)
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
|
||||
WorkspaceConnection,
|
||||
workspace_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class DockSettingsDialog(QDialog):
|
||||
|
||||
def __init__(self, parent: QWidget, target: QWidget):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Dock Settings")
|
||||
self.setModal(True)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Property editor
|
||||
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
|
||||
layout.addWidget(self.prop_editor)
|
||||
|
||||
|
||||
class SaveProfileDialog(QDialog):
|
||||
"""Dialog for saving workspace profiles with read-only option."""
|
||||
|
||||
def __init__(self, parent: QWidget, current_name: str = ""):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Save Workspace Profile")
|
||||
self.setModal(True)
|
||||
self.resize(400, 150)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Name input
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Profile Name:"))
|
||||
self.name_edit = QLineEdit(current_name)
|
||||
self.name_edit.setPlaceholderText("Enter profile name...")
|
||||
name_row.addWidget(self.name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
# Read-only checkbox
|
||||
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
|
||||
layout.addWidget(self.readonly_checkbox)
|
||||
|
||||
# Info label
|
||||
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
|
||||
info_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Buttons
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch(1)
|
||||
self.save_btn = QPushButton("Save")
|
||||
self.save_btn.setDefault(True)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
self.save_btn.clicked.connect(self.accept)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(self.save_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# Enable/disable save button based on name input
|
||||
self.name_edit.textChanged.connect(self._update_save_button)
|
||||
self._update_save_button()
|
||||
|
||||
def _update_save_button(self):
|
||||
"""Enable save button only when name is not empty."""
|
||||
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
|
||||
|
||||
def get_profile_name(self) -> str:
|
||||
"""Get the entered profile name."""
|
||||
return self.name_edit.text().strip()
|
||||
|
||||
def is_readonly(self) -> bool:
|
||||
"""Check if the profile should be marked as read-only."""
|
||||
return self.readonly_checkbox.isChecked()
|
||||
|
||||
|
||||
class AdvancedDockArea(BECWidget, QWidget):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = [
|
||||
"new",
|
||||
"widget_map",
|
||||
"widget_list",
|
||||
"lock_workspace",
|
||||
"attach_all",
|
||||
"delete_all",
|
||||
"mode",
|
||||
"mode.setter",
|
||||
]
|
||||
|
||||
# Define a signal for mode changes
|
||||
mode_changed = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
mode: str = "developer",
|
||||
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Title (as a top-level QWidget it can have a window title)
|
||||
self.setWindowTitle("Advanced Dock Area")
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
|
||||
# Init Dock Manager
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
|
||||
# Dock manager helper variables
|
||||
self._locked = False # Lock state of the workspace
|
||||
|
||||
# Initialize mode property first (before toolbar setup)
|
||||
self._mode = "developer"
|
||||
self._default_add_direction = (
|
||||
default_add_direction
|
||||
if default_add_direction in ("left", "right", "top", "bottom")
|
||||
else "right"
|
||||
)
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self._setup_toolbar()
|
||||
self._hook_toolbar()
|
||||
|
||||
# Place toolbar and dock manager into layout
|
||||
self._root_layout.addWidget(self.toolbar)
|
||||
self._root_layout.addWidget(self.dock_manager, 1)
|
||||
|
||||
# Populate and hook the workspace combo
|
||||
self._refresh_workspace_list()
|
||||
|
||||
# State manager
|
||||
self.state_manager = WidgetStateManager(self)
|
||||
|
||||
# Developer mode state
|
||||
self._editable = None
|
||||
# Initialize default editable state based on current lock
|
||||
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||
|
||||
# Sync Developer toggle icon state after initial setup
|
||||
dev_action = self.toolbar.components.get_action("developer_mode").action
|
||||
dev_action.setChecked(self._editable)
|
||||
|
||||
# Apply the requested mode after everything is set up
|
||||
self.mode = mode
|
||||
|
||||
def _make_dock(
|
||||
self,
|
||||
widget: QWidget,
|
||||
*,
|
||||
closable: bool,
|
||||
floatable: bool,
|
||||
movable: bool = True,
|
||||
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
start_floating: bool = False,
|
||||
) -> CDockWidget:
|
||||
dock = CDockWidget(widget.objectName())
|
||||
dock.setWidget(widget)
|
||||
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
|
||||
dock.setFeature(CDockWidget.CustomCloseHandling, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
|
||||
|
||||
self._install_dock_settings_action(dock, widget)
|
||||
|
||||
def on_dock_close():
|
||||
widget.close()
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
def on_widget_destroyed():
|
||||
if not isValid(dock):
|
||||
return
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
dock.closeRequested.connect(on_dock_close)
|
||||
if hasattr(widget, "widget_removed"):
|
||||
widget.widget_removed.connect(on_widget_destroyed)
|
||||
|
||||
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
|
||||
self.dock_manager.addDockWidget(area, dock)
|
||||
if start_floating:
|
||||
dock.setFloating()
|
||||
return dock
|
||||
|
||||
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
action = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
|
||||
).action
|
||||
action.setToolTip("Dock settings")
|
||||
action.setObjectName("dockSettingsAction")
|
||||
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
|
||||
dock.setTitleBarActions([action])
|
||||
dock.setting_action = action
|
||||
|
||||
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
dlg = DockSettingsDialog(self, widget)
|
||||
dlg.resize(600, 600)
|
||||
dlg.exec()
|
||||
|
||||
def _apply_dock_lock(self, locked: bool) -> None:
|
||||
if locked:
|
||||
self.dock_manager.lockDockWidgetFeaturesGlobally()
|
||||
else:
|
||||
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
|
||||
|
||||
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||
w = dock.widget()
|
||||
if w and isValid(w):
|
||||
w.close()
|
||||
w.deleteLater()
|
||||
if isValid(dock):
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
|
||||
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
|
||||
"""Return ADS DockWidgetArea from a human-friendly direction string.
|
||||
If *where* is None, fall back to instance default.
|
||||
"""
|
||||
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
|
||||
mapping = {
|
||||
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
|
||||
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
|
||||
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
|
||||
}
|
||||
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
|
||||
|
||||
################################################################################
|
||||
# Toolbar Setup
|
||||
################################################################################
|
||||
|
||||
def _setup_toolbar(self):
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
|
||||
PLOT_ACTIONS = {
|
||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||
"scatter_waveform": (
|
||||
ScatterWaveform.ICON_NAME,
|
||||
"Add Scatter Waveform",
|
||||
"ScatterWaveform",
|
||||
),
|
||||
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
|
||||
"image": (Image.ICON_NAME, "Add Image", "Image"),
|
||||
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
||||
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
||||
}
|
||||
DEVICE_ACTIONS = {
|
||||
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
|
||||
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
|
||||
}
|
||||
UTIL_ACTIONS = {
|
||||
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
|
||||
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
|
||||
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
|
||||
"progress_bar": (
|
||||
RingProgressBar.ICON_NAME,
|
||||
"Add Circular ProgressBar",
|
||||
"RingProgressBar",
|
||||
),
|
||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
self.toolbar.components.add_safe(
|
||||
key,
|
||||
ExpandableMenuAction(
|
||||
label=label,
|
||||
actions={
|
||||
k: MaterialIconAction(
|
||||
icon_name=v[0], tooltip=v[1], filled=True, parent=self
|
||||
)
|
||||
for k, v in mapping.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
b = ToolbarBundle(key, self.toolbar.components)
|
||||
b.add_action(key)
|
||||
self.toolbar.add_bundle(b)
|
||||
|
||||
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
|
||||
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
|
||||
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
|
||||
|
||||
# Create flat toolbar bundles for each widget type
|
||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||
|
||||
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
|
||||
# Create individual action for each widget type
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
self.toolbar.components.add_safe(
|
||||
flat_action_id,
|
||||
MaterialIconAction(
|
||||
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
|
||||
),
|
||||
)
|
||||
bundle.add_action(flat_action_id)
|
||||
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
_build_flat_bundles("plots", PLOT_ACTIONS)
|
||||
_build_flat_bundles("devices", DEVICE_ACTIONS)
|
||||
_build_flat_bundles("utils", UTIL_ACTIONS)
|
||||
|
||||
# Workspace
|
||||
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
|
||||
spacer = QWidget(parent=self.toolbar.components.toolbar)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
spacer_bundle.add_action("spacer")
|
||||
self.toolbar.add_bundle(spacer_bundle)
|
||||
|
||||
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
|
||||
)
|
||||
|
||||
# Dock actions
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"screenshot",
|
||||
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
|
||||
)
|
||||
# Developer mode toggle (moved from menu into toolbar)
|
||||
self.toolbar.components.add_safe(
|
||||
"developer_mode",
|
||||
MaterialIconAction(
|
||||
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
bda = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bda.add_action("attach_all")
|
||||
bda.add_action("screenshot")
|
||||
bda.add_action("dark_mode")
|
||||
bda.add_action("developer_mode")
|
||||
self.toolbar.add_bundle(bda)
|
||||
|
||||
# Default bundle configuration (show menus by default)
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
|
||||
# Store mappings on self for use in _hook_toolbar
|
||||
self._ACTION_MAPPINGS = {
|
||||
"menu_plots": PLOT_ACTIONS,
|
||||
"menu_devices": DEVICE_ACTIONS,
|
||||
"menu_utils": UTIL_ACTIONS,
|
||||
}
|
||||
|
||||
def _hook_toolbar(self):
|
||||
|
||||
def _connect_menu(menu_key: str):
|
||||
menu = self.toolbar.components.get_action(menu_key)
|
||||
mapping = self._ACTION_MAPPINGS[menu_key]
|
||||
for key, (_, _, widget_type) in mapping.items():
|
||||
act = menu.actions[key].action
|
||||
if widget_type == "LogPanel":
|
||||
act.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_menu("menu_plots")
|
||||
_connect_menu("menu_devices")
|
||||
_connect_menu("menu_utils")
|
||||
|
||||
# Connect flat toolbar actions
|
||||
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||
for action_id, (_, _, widget_type) in mapping.items():
|
||||
flat_action_id = f"flat_{action_id}"
|
||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||
if widget_type == "LogPanel":
|
||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
||||
else:
|
||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||
|
||||
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
|
||||
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
|
||||
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
|
||||
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
# Developer mode toggle
|
||||
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
|
||||
self._on_developer_mode_toggled
|
||||
)
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
self.lock_workspace = not editable
|
||||
self._editable = editable
|
||||
|
||||
# Sync the toolbar lock toggle with current mode
|
||||
lock_action = self.toolbar.components.get_action("lock").action
|
||||
lock_action.setChecked(not editable)
|
||||
lock_action.setVisible(editable)
|
||||
|
||||
attach_all_action = self.toolbar.components.get_action("attach_all").action
|
||||
attach_all_action.setVisible(editable)
|
||||
|
||||
# Show full creation menus only when editable; otherwise keep minimal set
|
||||
if editable:
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
else:
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
|
||||
# Keep Developer mode UI in sync
|
||||
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
|
||||
|
||||
def _on_developer_mode_toggled(self, checked: bool) -> None:
|
||||
"""Handle developer mode checkbox toggle."""
|
||||
self._set_editable(checked)
|
||||
|
||||
################################################################################
|
||||
# Adding widgets
|
||||
################################################################################
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
closable: bool = True,
|
||||
floatable: bool = True,
|
||||
movable: bool = True,
|
||||
start_floating: bool = False,
|
||||
where: Literal["left", "right", "top", "bottom"] | None = None,
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Create a new widget (or reuse an instance) and add it as a dock.
|
||||
|
||||
Args:
|
||||
widget: Widget instance or a string widget type (factory-created).
|
||||
closable: Whether the dock is closable.
|
||||
floatable: Whether the dock is floatable.
|
||||
movable: Whether the dock is movable.
|
||||
start_floating: Start the dock in a floating state.
|
||||
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
|
||||
If None, uses the instance default passed at construction time.
|
||||
Returns:
|
||||
The widget instance.
|
||||
"""
|
||||
target_area = self._area_from_where(where)
|
||||
|
||||
# 1) Instantiate or look up the widget
|
||||
if isinstance(widget, str):
|
||||
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
|
||||
widget.name_established.connect(
|
||||
lambda: self._create_dock_with_name(
|
||||
widget=widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
area=target_area,
|
||||
)
|
||||
)
|
||||
return widget
|
||||
|
||||
# If a widget instance is passed, dock it immediately
|
||||
self._create_dock_with_name(
|
||||
widget=widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
start_floating=start_floating,
|
||||
area=target_area,
|
||||
)
|
||||
return widget
|
||||
|
||||
def _create_dock_with_name(
|
||||
self,
|
||||
widget: BECWidget,
|
||||
closable: bool = True,
|
||||
floatable: bool = False,
|
||||
movable: bool = True,
|
||||
start_floating: bool = False,
|
||||
area: QtAds.DockWidgetArea | None = None,
|
||||
):
|
||||
target_area = area or self._area_from_where(None)
|
||||
self._make_dock(
|
||||
widget,
|
||||
closable=closable,
|
||||
floatable=floatable,
|
||||
movable=movable,
|
||||
area=target_area,
|
||||
start_floating=start_floating,
|
||||
)
|
||||
self.dock_manager.setFocus()
|
||||
|
||||
################################################################################
|
||||
# Dock Management
|
||||
################################################################################
|
||||
|
||||
def dock_map(self) -> dict[str, CDockWidget]:
|
||||
"""
|
||||
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to their corresponding dock widgets.
|
||||
"""
|
||||
return self.dock_manager.dockWidgetsMap()
|
||||
|
||||
def dock_list(self) -> list[CDockWidget]:
|
||||
"""
|
||||
Return the list of dock widgets.
|
||||
|
||||
Returns:
|
||||
list: A list of all dock widgets in the dock area.
|
||||
"""
|
||||
return self.dock_manager.dockWidgets()
|
||||
|
||||
def widget_map(self) -> dict[str, QWidget]:
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding BECWidget instances.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping widget names to BECWidget instances.
|
||||
"""
|
||||
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||
|
||||
def widget_list(self) -> list[QWidget]:
|
||||
"""
|
||||
Return a list of all BECWidget instances in the dock area.
|
||||
|
||||
Returns:
|
||||
list: A list of all BECWidget instances in the dock area.
|
||||
"""
|
||||
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||
|
||||
@SafeSlot()
|
||||
def attach_all(self):
|
||||
"""
|
||||
Return all floating docks to the dock area, preserving tab groups within each floating container.
|
||||
"""
|
||||
for container in self.dock_manager.floatingWidgets():
|
||||
docks = container.dockWidgets()
|
||||
if not docks:
|
||||
continue
|
||||
target = docks[0]
|
||||
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
|
||||
for d in docks[1:]:
|
||||
self.dock_manager.addDockWidgetTab(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_all(self):
|
||||
"""Delete all docks and widgets."""
|
||||
for dock in list(self.dock_manager.dockWidgets()):
|
||||
self._delete_dock(dock)
|
||||
|
||||
################################################################################
|
||||
# Workspace Management
|
||||
################################################################################
|
||||
@SafeProperty(bool)
|
||||
def lock_workspace(self) -> bool:
|
||||
"""
|
||||
Get or set the lock state of the workspace.
|
||||
|
||||
Returns:
|
||||
bool: True if the workspace is locked, False otherwise.
|
||||
"""
|
||||
return self._locked
|
||||
|
||||
@lock_workspace.setter
|
||||
def lock_workspace(self, value: bool):
|
||||
"""
|
||||
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
|
||||
|
||||
Args:
|
||||
value (bool): True to lock the workspace, False to unlock it.
|
||||
"""
|
||||
self._locked = value
|
||||
self._apply_dock_lock(value)
|
||||
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
|
||||
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
|
||||
for dock in self.dock_list():
|
||||
dock.setting_action.setVisible(not value)
|
||||
|
||||
@SafeSlot(str)
|
||||
def save_profile(self, name: str | None = None):
|
||||
"""
|
||||
Save the current workspace profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||
"""
|
||||
if not name:
|
||||
# Use the new SaveProfileDialog instead of QInputDialog
|
||||
dialog = SaveProfileDialog(self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
name = dialog.get_profile_name()
|
||||
readonly = dialog.is_readonly()
|
||||
|
||||
# Check if profile already exists and is read-only
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
suggested_name = f"{name}_custom"
|
||||
reply = QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
|
||||
f"Would you like to save it with a different name?\n"
|
||||
f"Suggested name: '{suggested_name}'",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.Yes,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
# Show dialog again with suggested name pre-filled
|
||||
dialog = SaveProfileDialog(self, suggested_name)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
name = dialog.get_profile_name()
|
||||
readonly = dialog.is_readonly()
|
||||
|
||||
# Check again if the new name is also read-only (recursive protection)
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
return self.save_profile()
|
||||
else:
|
||||
return
|
||||
else:
|
||||
# If name is provided directly, assume not read-only unless already exists
|
||||
readonly = False
|
||||
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
|
||||
QMessageBox.Ok,
|
||||
)
|
||||
return
|
||||
|
||||
# Display saving placeholder
|
||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
workspace_combo.blockSignals(True)
|
||||
workspace_combo.insertItem(0, f"{name}-saving")
|
||||
workspace_combo.setCurrentIndex(0)
|
||||
workspace_combo.blockSignals(False)
|
||||
|
||||
# Save the profile
|
||||
settings = open_settings(name)
|
||||
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
|
||||
settings.setValue(
|
||||
SETTINGS_KEYS["state"], b""
|
||||
) # No QMainWindow state; placeholder for backward compat
|
||||
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
|
||||
self.dock_manager.addPerspective(name)
|
||||
self.dock_manager.savePerspectives(settings)
|
||||
self.state_manager.save_state(settings=settings)
|
||||
write_manifest(settings, self.dock_list())
|
||||
|
||||
# Set read-only status if specified
|
||||
if readonly:
|
||||
set_profile_readonly(name, readonly)
|
||||
|
||||
settings.sync()
|
||||
self._refresh_workspace_list()
|
||||
workspace_combo.setCurrentText(name)
|
||||
|
||||
def load_profile(self, name: str | None = None):
|
||||
"""
|
||||
Load a workspace profile.
|
||||
|
||||
Args:
|
||||
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||
"""
|
||||
# FIXME this has to be tweaked
|
||||
if not name:
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
settings = open_settings(name)
|
||||
|
||||
for item in read_manifest(settings):
|
||||
obj_name = item["object_name"]
|
||||
widget_class = item["widget_class"]
|
||||
if obj_name not in self.widget_map():
|
||||
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
|
||||
w.setObjectName(obj_name)
|
||||
self._make_dock(
|
||||
w,
|
||||
closable=item["closable"],
|
||||
floatable=item["floatable"],
|
||||
movable=item["movable"],
|
||||
area=QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
)
|
||||
|
||||
geom = settings.value(SETTINGS_KEYS["geom"])
|
||||
if geom:
|
||||
self.restoreGeometry(geom)
|
||||
# No window state for QWidget-based host; keep for backwards compat read
|
||||
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
|
||||
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
|
||||
if dock_state:
|
||||
self.dock_manager.restoreState(dock_state)
|
||||
self.dock_manager.loadPerspectives(settings)
|
||||
self.state_manager.load_state(settings=settings)
|
||||
self._set_editable(self._editable)
|
||||
|
||||
@SafeSlot()
|
||||
def delete_profile(self):
|
||||
"""
|
||||
Delete the currently selected workspace profile file and refresh the combo list.
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
name = combo.currentText()
|
||||
if not name:
|
||||
return
|
||||
|
||||
# Check if profile is read-only
|
||||
if is_profile_readonly(name):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Read-only Profile",
|
||||
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
|
||||
f"Read-only profiles are protected from modification and deletion.",
|
||||
QMessageBox.Ok,
|
||||
)
|
||||
return
|
||||
|
||||
# Confirm deletion for regular profiles
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Are you sure you want to delete the profile '{name}'?\n\n"
|
||||
f"This action cannot be undone.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
file_path = profile_path(name)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
self._refresh_workspace_list()
|
||||
|
||||
def _refresh_workspace_list(self):
|
||||
"""
|
||||
Populate the workspace combo box with all saved profile names (without .ini).
|
||||
"""
|
||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||
if hasattr(combo, "refresh_profiles"):
|
||||
combo.refresh_profiles()
|
||||
else:
|
||||
# Fallback for regular QComboBox
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(list_profiles())
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Mode Switching
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
@mode.setter
|
||||
def mode(self, new_mode: str):
|
||||
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
|
||||
raise ValueError(f"Invalid mode: {new_mode}")
|
||||
self._mode = new_mode
|
||||
self.mode_changed.emit(new_mode)
|
||||
|
||||
# Update toolbar visibility based on mode
|
||||
if new_mode == "user":
|
||||
# User mode: show only essential tools
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
elif new_mode == "developer":
|
||||
# Developer mode: show all tools (use menu bundles)
|
||||
self.toolbar.show_bundles(
|
||||
[
|
||||
"menu_plots",
|
||||
"menu_devices",
|
||||
"menu_utils",
|
||||
"spacer_bundle",
|
||||
"workspace",
|
||||
"dock_actions",
|
||||
]
|
||||
)
|
||||
elif new_mode in ["plot", "device", "utils"]:
|
||||
# Specific modes: show flat toolbar for that category
|
||||
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
|
||||
self.toolbar.show_bundles([bundle_name])
|
||||
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
|
||||
else:
|
||||
# Fallback to user mode
|
||||
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.dark_mode_button.close()
|
||||
self.dark_mode_button.deleteLater()
|
||||
self.toolbar.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
ads = AdvancedDockArea(mode="developer", root_widget=True)
|
||||
window.setCentralWidget(ads)
|
||||
window.show()
|
||||
window.resize(800, 600)
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
|
||||
from PySide6QtAds import CDockWidget
|
||||
from qtpy.QtCore import QSettings
|
||||
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
|
||||
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
|
||||
|
||||
|
||||
def profiles_dir() -> str:
|
||||
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def profile_path(name: str) -> str:
|
||||
return os.path.join(profiles_dir(), f"{name}.ini")
|
||||
|
||||
|
||||
SETTINGS_KEYS = {
|
||||
"geom": "mainWindow/Geometry",
|
||||
"state": "mainWindow/State",
|
||||
"ads_state": "mainWindow/DockingState",
|
||||
"manifest": "manifest/widgets",
|
||||
"readonly": "profile/readonly",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini"))
|
||||
|
||||
|
||||
def is_profile_readonly(name: str) -> bool:
|
||||
"""Check if a profile is marked as read-only."""
|
||||
settings = open_settings(name)
|
||||
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
|
||||
|
||||
|
||||
def set_profile_readonly(name: str, readonly: bool) -> None:
|
||||
"""Set the read-only status of a profile."""
|
||||
settings = open_settings(name)
|
||||
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
|
||||
settings.sync()
|
||||
|
||||
|
||||
def open_settings(name: str) -> QSettings:
|
||||
return QSettings(profile_path(name), QSettings.IniFormat)
|
||||
|
||||
|
||||
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
|
||||
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
|
||||
for i, dock in enumerate(docks):
|
||||
settings.setArrayIndex(i)
|
||||
w = dock.widget()
|
||||
settings.setValue("object_name", w.objectName())
|
||||
settings.setValue("widget_class", w.__class__.__name__)
|
||||
settings.setValue("closable", getattr(dock, "_default_closable", True))
|
||||
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
|
||||
settings.setValue("movable", getattr(dock, "_default_movable", True))
|
||||
settings.endArray()
|
||||
|
||||
|
||||
def read_manifest(settings: QSettings) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
|
||||
for i in range(count):
|
||||
settings.setArrayIndex(i)
|
||||
items.append(
|
||||
{
|
||||
"object_name": settings.value("object_name"),
|
||||
"widget_class": settings.value("widget_class"),
|
||||
"closable": settings.value("closable", type=bool),
|
||||
"floatable": settings.value("floatable", type=bool),
|
||||
"movable": settings.value("movable", type=bool),
|
||||
}
|
||||
)
|
||||
settings.endArray()
|
||||
return items
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
|
||||
|
||||
from bec_widgets import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||
is_profile_readonly,
|
||||
list_profiles,
|
||||
)
|
||||
|
||||
|
||||
class ProfileComboBox(QComboBox):
|
||||
"""Custom combobox that displays icons for read-only profiles."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
def refresh_profiles(self):
|
||||
"""Refresh the profile list with appropriate icons."""
|
||||
|
||||
current_text = self.currentText()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
|
||||
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
|
||||
|
||||
for profile in list_profiles():
|
||||
if is_profile_readonly(profile):
|
||||
self.addItem(lock_icon, f"{profile}")
|
||||
# Set tooltip for read-only profiles
|
||||
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
|
||||
else:
|
||||
self.addItem(profile)
|
||||
|
||||
# Restore selection if possible
|
||||
index = self.findText(current_text)
|
||||
if index >= 0:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
self.blockSignals(False)
|
||||
|
||||
|
||||
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The workspace toolbar bundle.
|
||||
"""
|
||||
# Lock icon action
|
||||
components.add_safe(
|
||||
"lock",
|
||||
MaterialIconAction(
|
||||
icon_name="lock_open_right",
|
||||
tooltip="Lock Workspace",
|
||||
checkable=True,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
|
||||
# Workspace combo
|
||||
combo = ProfileComboBox(parent=components.toolbar)
|
||||
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
|
||||
|
||||
# Save the current workspace icon
|
||||
components.add_safe(
|
||||
"save_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="save",
|
||||
tooltip="Save Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
# Delete workspace icon
|
||||
components.add_safe(
|
||||
"refresh_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="refresh",
|
||||
tooltip="Refresh Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
# Delete workspace icon
|
||||
components.add_safe(
|
||||
"delete_workspace",
|
||||
MaterialIconAction(
|
||||
icon_name="delete",
|
||||
tooltip="Delete Current Workspace",
|
||||
checkable=False,
|
||||
parent=components.toolbar,
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("workspace", components)
|
||||
bundle.add_action("lock")
|
||||
bundle.add_action("workspace_combo")
|
||||
bundle.add_action("save_workspace")
|
||||
bundle.add_action("refresh_workspace")
|
||||
bundle.add_action("delete_workspace")
|
||||
return bundle
|
||||
|
||||
|
||||
class WorkspaceConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for workspace actions in AdvancedDockArea.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
super().__init__(parent=components.toolbar)
|
||||
self.bundle_name = "workspace"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "lock_workspace"):
|
||||
raise AttributeError("Target widget must implement 'lock_workspace'.")
|
||||
self._connected = False
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
|
||||
self.components.get_action("save_workspace").action.triggered.connect(
|
||||
self.target_widget.save_profile
|
||||
)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
self.components.get_action("refresh_workspace").action.triggered.connect(
|
||||
self._refresh_workspace
|
||||
)
|
||||
self.components.get_action("delete_workspace").action.triggered.connect(
|
||||
self.target_widget.delete_profile
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
|
||||
self.components.get_action("save_workspace").action.triggered.disconnect(
|
||||
self.target_widget.save_profile
|
||||
)
|
||||
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
|
||||
self.target_widget.load_profile
|
||||
)
|
||||
self.components.get_action("refresh_workspace").action.triggered.disconnect(
|
||||
self._refresh_workspace
|
||||
)
|
||||
self.components.get_action("delete_workspace").action.triggered.disconnect(
|
||||
self.target_widget.delete_profile
|
||||
)
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _lock_workspace(self, value: bool):
|
||||
"""
|
||||
Switches the workspace lock state and change the icon accordingly.
|
||||
"""
|
||||
setattr(self.target_widget, "lock_workspace", value)
|
||||
self.components.get_action("lock").action.setChecked(value)
|
||||
icon = material_icon(
|
||||
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
|
||||
)
|
||||
self.components.get_action("lock").action.setIcon(icon)
|
||||
|
||||
@SafeSlot()
|
||||
def _refresh_workspace(self):
|
||||
"""
|
||||
Refreshes the current workspace.
|
||||
"""
|
||||
combo = self.components.get_action("workspace_combo").widget
|
||||
current_workspace = combo.currentText()
|
||||
self.target_widget.load_profile(current_workspace)
|
||||
@@ -27,6 +27,7 @@ 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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['dock_area.py']}
|
||||
@@ -1,22 +1,19 @@
|
||||
# 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 import BECDockArea
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECDockArea' name='dock_area'>
|
||||
<widget class='BECDockArea' name='bec_dock_area'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
@@ -24,6 +21,8 @@ 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
|
||||
|
||||
@@ -31,13 +30,13 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Plots"
|
||||
return "BEC Containers"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECDockArea.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "dock_area"
|
||||
return "bec_dock_area"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -52,7 +51,7 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "BECDockArea"
|
||||
|
||||
def toolTip(self):
|
||||
return "BECDockArea"
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -389,6 +389,7 @@ class BECDock(BECWidget, Dock):
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
def delete_all(self):
|
||||
"""
|
||||
|
||||
@@ -71,6 +71,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"save_state",
|
||||
"screenshot",
|
||||
"restore_state",
|
||||
]
|
||||
|
||||
@@ -267,11 +268,16 @@ 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):
|
||||
@@ -333,6 +339,7 @@ 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:
|
||||
@@ -609,10 +616,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
apply_theme("dark")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
||||
dock_1.new(widget="DarkModeButton")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['dock_area.py','dock.py']}
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin
|
||||
from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())
|
||||
|
||||
0
bec_widgets/widgets/containers/explorer/__init__.py
Normal file
0
bec_widgets/widgets/containers/explorer/__init__.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
|
||||
class CollapsibleSection(QWidget):
|
||||
"""A widget that combines a header button with any content widget for collapsible sections
|
||||
|
||||
This widget contains a header button with a title and a content widget.
|
||||
The content widget can be any QWidget. The header button can be expanded or collapsed.
|
||||
The header also contains an "Add" button that is only visible when hovering over the section.
|
||||
|
||||
Signals:
|
||||
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
|
||||
onto another section for reordering.
|
||||
Arguments are (source_title, target_title).
|
||||
"""
|
||||
|
||||
section_reorder_requested = Signal(str, str) # (source_title, target_title)
|
||||
|
||||
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
|
||||
super().__init__(parent=parent)
|
||||
self.title = title
|
||||
self.content_widget = None
|
||||
self.setAcceptDrops(True)
|
||||
self._expanded = True
|
||||
|
||||
# Setup layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 4, 0)
|
||||
header_layout.setSpacing(0)
|
||||
|
||||
# Create header button
|
||||
self.header_button = QPushButton()
|
||||
self.header_button.clicked.connect(self.toggle_expanded)
|
||||
|
||||
# Enable drag and drop for reordering
|
||||
self.header_button.setAcceptDrops(True)
|
||||
self.header_button.mousePressEvent = self._header_mouse_press_event
|
||||
self.header_button.mouseMoveEvent = self._header_mouse_move_event
|
||||
self.header_button.dragEnterEvent = self._header_drag_enter_event
|
||||
self.header_button.dropEvent = self._header_drop_event
|
||||
|
||||
self.drag_start_position = None
|
||||
|
||||
# Add header to layout
|
||||
header_layout.addWidget(self.header_button)
|
||||
header_layout.addStretch()
|
||||
|
||||
self.header_add_button = QPushButton()
|
||||
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.header_add_button.setFixedSize(20, 20)
|
||||
self.header_add_button.setToolTip("Add item")
|
||||
self.header_add_button.setVisible(show_add_button)
|
||||
|
||||
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
|
||||
header_layout.addWidget(self.header_add_button)
|
||||
|
||||
self.main_layout.addLayout(header_layout)
|
||||
|
||||
self._update_expanded_state()
|
||||
|
||||
def set_widget(self, widget):
|
||||
"""Set the content widget for this collapsible section"""
|
||||
# Remove existing content widget if any
|
||||
if self.content_widget and self.content_widget.parent() == self:
|
||||
self.main_layout.removeWidget(self.content_widget)
|
||||
self.content_widget.close()
|
||||
self.content_widget.deleteLater()
|
||||
|
||||
self.content_widget = widget
|
||||
if self.content_widget:
|
||||
self.main_layout.addWidget(self.content_widget)
|
||||
|
||||
self._update_expanded_state()
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the header button appearance based on expanded state"""
|
||||
# Use material icons with consistent sizing to match tree items
|
||||
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
|
||||
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
|
||||
|
||||
self.header_button.setIcon(icon)
|
||||
self.header_button.setText(self.title)
|
||||
|
||||
# Get theme colors
|
||||
palette = get_theme_palette()
|
||||
text_color = palette.text().color().name()
|
||||
|
||||
self.header_button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: {text_color};
|
||||
icon-size: 20px 20px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def toggle_expanded(self):
|
||||
"""Toggle the expanded state and update size policy"""
|
||||
self.expanded = not self.expanded
|
||||
self._update_expanded_state()
|
||||
|
||||
def _update_expanded_state(self):
|
||||
"""Update the expanded state based on current state"""
|
||||
self._update_appearance()
|
||||
if self.expanded:
|
||||
if self.content_widget:
|
||||
self.content_widget.show()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
else:
|
||||
if self.content_widget:
|
||||
self.content_widget.hide()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self) -> bool:
|
||||
"""Get the expanded state"""
|
||||
return self._expanded
|
||||
|
||||
@expanded.setter
|
||||
def expanded(self, value: bool):
|
||||
"""Set the expanded state programmatically"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("Expanded state must be a boolean")
|
||||
if self._expanded == value:
|
||||
return
|
||||
self._expanded = value
|
||||
self._update_appearance()
|
||||
|
||||
def connect_add_button(self, slot):
|
||||
"""Connect a slot to the add button's clicked signal.
|
||||
|
||||
Args:
|
||||
slot: The function to call when the add button is clicked.
|
||||
"""
|
||||
self.header_add_button.clicked.connect(slot)
|
||||
|
||||
def _header_mouse_press_event(self, event):
|
||||
"""Handle mouse press on header for drag start"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.drag_start_position = event.pos()
|
||||
QPushButton.mousePressEvent(self.header_button, event)
|
||||
|
||||
def _header_mouse_move_event(self, event):
|
||||
"""Handle mouse move to start drag operation"""
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
|
||||
|
||||
# Check if we've moved far enough to start a drag
|
||||
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
|
||||
|
||||
self._start_drag()
|
||||
QPushButton.mouseMoveEvent(self.header_button, event)
|
||||
|
||||
def _start_drag(self):
|
||||
"""Start the drag operation with a properly aligned widget pixmap"""
|
||||
drag = QDrag(self.header_button)
|
||||
mime_data = QMimeData()
|
||||
mime_data.setText(f"section:{self.title}")
|
||||
drag.setMimeData(mime_data)
|
||||
|
||||
# Grab a pixmap of the widget
|
||||
widget_pixmap = self.header_button.grab()
|
||||
|
||||
drag.setPixmap(widget_pixmap)
|
||||
|
||||
# Set the hotspot to where the mouse was pressed on the widget
|
||||
drag.setHotSpot(self.drag_start_position)
|
||||
|
||||
drag.exec_(Qt.MoveAction)
|
||||
|
||||
def _header_drag_enter_event(self, event):
|
||||
"""Handle drag enter on header"""
|
||||
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def _header_drop_event(self, event):
|
||||
"""Handle drop on header"""
|
||||
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
|
||||
source_title = event.mimeData().text().replace("section:", "")
|
||||
if source_title != self.title:
|
||||
# Emit signal to parent to handle reordering
|
||||
self.section_reorder_requested.emit(source_title, self.title)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
179
bec_widgets/widgets/containers/explorer/explorer.py
Normal file
179
bec_widgets/widgets/containers/explorer/explorer.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
|
||||
|
||||
class Explorer(BECWidget, QWidget):
|
||||
"""
|
||||
A widget that combines multiple collapsible sections for an explorer-like interface.
|
||||
Each section can be expanded or collapsed, and sections can be reordered. The explorer
|
||||
can contain also sub-explorers for nested structures.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Main layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
# Splitter for sections
|
||||
self.splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
self.main_layout.addWidget(self.splitter)
|
||||
|
||||
# Spacer for when all sections are collapsed
|
||||
self.expander = QSpacerItem(0, 0)
|
||||
self.main_layout.addItem(self.expander)
|
||||
|
||||
# Registry of sections
|
||||
self.sections: list[CollapsibleSection] = []
|
||||
|
||||
# Setup splitter styling
|
||||
self._setup_splitter_styling()
|
||||
|
||||
def add_section(self, section: CollapsibleSection) -> None:
|
||||
"""
|
||||
Add a collapsible section to the explorer
|
||||
|
||||
Args:
|
||||
section (CollapsibleSection): The section to add
|
||||
"""
|
||||
if not isinstance(section, CollapsibleSection):
|
||||
raise TypeError("section must be an instance of CollapsibleSection")
|
||||
|
||||
if section in self.sections:
|
||||
return
|
||||
|
||||
self.sections.append(section)
|
||||
self.splitter.addWidget(section)
|
||||
|
||||
# Connect the section's toggle to update spacer
|
||||
section.header_button.clicked.connect(self._update_spacer)
|
||||
|
||||
# Connect section reordering if supported
|
||||
if hasattr(section, "section_reorder_requested"):
|
||||
section.section_reorder_requested.connect(self._handle_section_reorder)
|
||||
|
||||
self._update_spacer()
|
||||
|
||||
def remove_section(self, section: CollapsibleSection) -> None:
|
||||
"""
|
||||
Remove a collapsible section from the explorer
|
||||
|
||||
Args:
|
||||
section (CollapsibleSection): The section to remove
|
||||
"""
|
||||
if section not in self.sections:
|
||||
return
|
||||
self.sections.remove(section)
|
||||
section.deleteLater()
|
||||
section.close()
|
||||
|
||||
# Disconnect signals
|
||||
try:
|
||||
section.header_button.clicked.disconnect(self._update_spacer)
|
||||
if hasattr(section, "section_reorder_requested"):
|
||||
section.section_reorder_requested.disconnect(self._handle_section_reorder)
|
||||
except RuntimeError:
|
||||
# Signals already disconnected
|
||||
pass
|
||||
|
||||
self._update_spacer()
|
||||
|
||||
def get_section(self, title: str) -> CollapsibleSection | None:
|
||||
"""Get a section by its title"""
|
||||
for section in self.sections:
|
||||
if section.title == title:
|
||||
return section
|
||||
return None
|
||||
|
||||
def _setup_splitter_styling(self) -> None:
|
||||
"""Setup the splitter styling with theme colors"""
|
||||
palette = get_theme_palette()
|
||||
separator_color = palette.mid().color()
|
||||
|
||||
self.splitter.setStyleSheet(
|
||||
f"""
|
||||
QSplitter::handle {{
|
||||
height: 0.1px;
|
||||
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def _update_spacer(self) -> None:
|
||||
"""Update the spacer size based on section states"""
|
||||
any_expanded = any(section.expanded for section in self.sections)
|
||||
|
||||
if any_expanded:
|
||||
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
else:
|
||||
self.expander.changeSize(
|
||||
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
|
||||
"""Handle reordering of sections"""
|
||||
if source_title == target_title:
|
||||
return
|
||||
|
||||
source_section = self.get_section(source_title)
|
||||
target_section = self.get_section(target_title)
|
||||
|
||||
if not source_section or not target_section:
|
||||
return
|
||||
|
||||
# Get current indices
|
||||
source_index = self.splitter.indexOf(source_section)
|
||||
target_index = self.splitter.indexOf(target_section)
|
||||
|
||||
if source_index == -1 or target_index == -1:
|
||||
return
|
||||
|
||||
# Insert at target position
|
||||
self.splitter.insertWidget(target_index, source_section)
|
||||
|
||||
# Update sections
|
||||
self.sections.remove(source_section)
|
||||
self.sections.insert(target_index, source_section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel
|
||||
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
app = QApplication([])
|
||||
explorer = Explorer()
|
||||
section = CollapsibleSection(title="SCRIPTS", indentation=0)
|
||||
|
||||
script_explorer = Explorer()
|
||||
script_widget = ScriptTreeWidget()
|
||||
local_scripts_section = CollapsibleSection(title="Local")
|
||||
local_scripts_section.set_widget(script_widget)
|
||||
script_widget.set_directory(os.path.abspath("./"))
|
||||
script_explorer.add_section(local_scripts_section)
|
||||
|
||||
section.set_widget(script_explorer)
|
||||
explorer.add_section(section)
|
||||
shared_script_section = CollapsibleSection(title="Shared")
|
||||
shared_script_widget = ScriptTreeWidget()
|
||||
shared_script_widget.set_directory(os.path.abspath("./"))
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
macros_section = CollapsibleSection(title="MACROS", indentation=0)
|
||||
macros_section.set_widget(QLabel("Macros will be implemented later"))
|
||||
explorer.add_section(macros_section)
|
||||
explorer.show()
|
||||
app.exec()
|
||||
387
bec_widgets/widgets/containers/explorer/script_tree_widget.py
Normal file
387
bec_widgets/widgets/containers/explorer/script_tree_widget.py
Normal file
@@ -0,0 +1,387 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtGui import QAction, QPainter
|
||||
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FileItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.file_actions: list[QAction] = []
|
||||
self.dir_actions: list[QAction] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_file_path = ""
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for files"""
|
||||
self.file_actions.append(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directories"""
|
||||
self.dir_actions.append(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions"""
|
||||
self.file_actions.clear()
|
||||
self.dir_actions.clear()
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Paint the item with action buttons on hover"""
|
||||
# Paint the default item
|
||||
super().paint(painter, option, index)
|
||||
|
||||
# Early return if not hovering over this item
|
||||
if index != self.hovered_index:
|
||||
return
|
||||
|
||||
tree_view = self.parent()
|
||||
if not isinstance(tree_view, QTreeView):
|
||||
return
|
||||
|
||||
proxy_model = tree_view.model()
|
||||
if not isinstance(proxy_model, QSortFilterProxyModel):
|
||||
return
|
||||
|
||||
source_index = proxy_model.mapToSource(index)
|
||||
source_model = proxy_model.sourceModel()
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
file_path = source_model.filePath(source_index)
|
||||
self.current_file_path = file_path
|
||||
|
||||
# Choose appropriate actions based on item type
|
||||
actions = self.dir_actions if is_dir else self.file_actions
|
||||
if actions:
|
||||
self._draw_action_buttons(painter, option, actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
spacing = 2
|
||||
|
||||
# Calculate total width needed for all buttons
|
||||
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
|
||||
|
||||
# Clear previous button rects and create new ones
|
||||
self.button_rects.clear()
|
||||
|
||||
# Calculate starting position (right side of the item)
|
||||
start_x = option.rect.right() - total_width - margin
|
||||
current_x = start_x
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Get theme colors for better integration
|
||||
palette = get_theme_palette()
|
||||
button_bg = palette.button().color()
|
||||
button_bg.setAlpha(150) # Semi-transparent
|
||||
|
||||
for action in actions:
|
||||
if not action.isVisible():
|
||||
continue
|
||||
|
||||
# Calculate button position
|
||||
button_rect = QRect(
|
||||
current_x,
|
||||
option.rect.top() + (option.rect.height() - button_size) // 2,
|
||||
button_size,
|
||||
button_size,
|
||||
)
|
||||
self.button_rects.append(button_rect)
|
||||
|
||||
# Draw button background
|
||||
painter.setBrush(button_bg)
|
||||
painter.setPen(palette.mid().color())
|
||||
painter.drawRoundedRect(button_rect, 3, 3)
|
||||
|
||||
# Draw action icon
|
||||
icon = action.icon()
|
||||
if not icon.isNull():
|
||||
icon_rect = button_rect.adjusted(2, 2, -2, -2)
|
||||
icon.paint(painter, icon_rect)
|
||||
|
||||
# Move to next button position
|
||||
current_x += button_size + spacing
|
||||
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse events for action buttons"""
|
||||
# Early return if not a left click
|
||||
if not (
|
||||
event.type() == event.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
# Early return if not a proxy model
|
||||
if not isinstance(model, QSortFilterProxyModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
source_index = model.mapToSource(index)
|
||||
source_model = model.sourceModel()
|
||||
|
||||
# Early return if not a file system model
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
actions = self.dir_actions if is_dir else self.file_actions
|
||||
|
||||
# Check which button was clicked
|
||||
visible_actions = [action for action in actions if action.isVisible()]
|
||||
for i, button_rect in enumerate(self.button_rects):
|
||||
if button_rect.contains(event.pos()) and i < len(visible_actions):
|
||||
# Trigger the action
|
||||
visible_actions[i].trigger()
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def set_hovered_index(self, index):
|
||||
"""Set the currently hovered index"""
|
||||
self.hovered_index = index
|
||||
|
||||
|
||||
class ScriptTreeWidget(QWidget):
|
||||
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
|
||||
|
||||
file_selected = Signal(str) # Script file path selected
|
||||
file_open_requested = Signal(str) # File open button clicked
|
||||
file_renamed = Signal(str, str) # Old path, new path
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Create layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
# Enable mouse tracking for hover effects
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create file system model
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilterDisables(False)
|
||||
|
||||
# Create proxy model to filter out underscore directories
|
||||
self.proxy_model = QSortFilterProxyModel()
|
||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||
self.proxy_model.setSourceModel(self.model)
|
||||
self.tree.setModel(self.proxy_model)
|
||||
|
||||
# Create and set custom delegate
|
||||
self.delegate = FileItemDelegate(self.tree)
|
||||
self.tree.setItemDelegate(self.delegate)
|
||||
|
||||
# Add default open button for files
|
||||
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
|
||||
action.action.triggered.connect(self._on_file_open_requested)
|
||||
self.delegate.add_file_action(action.action)
|
||||
|
||||
# Remove unnecessary columns
|
||||
self.tree.setColumnHidden(1, True) # Hide size column
|
||||
self.tree.setColumnHidden(2, True) # Hide type column
|
||||
self.tree.setColumnHidden(3, True) # Hide date modified column
|
||||
|
||||
# Apply BEC styling
|
||||
self._apply_styling()
|
||||
|
||||
# Script specific properties
|
||||
self.directory = None
|
||||
|
||||
# Connect signals
|
||||
self.tree.clicked.connect(self._on_item_clicked)
|
||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||
|
||||
# Install event filter for hover tracking
|
||||
self.tree.viewport().installEventFilter(self)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
def _apply_styling(self):
|
||||
"""Apply styling to the tree widget"""
|
||||
# Get theme colors for subtle tree lines
|
||||
palette = get_theme_palette()
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
QTreeView::item {{
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}}
|
||||
QTreeView::item:hover {{
|
||||
background: palette(midlight);
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
QTreeView::item:selected {{
|
||||
background: palette(highlight);
|
||||
color: palette(highlighted-text);
|
||||
}}
|
||||
QTreeView::item:selected:hover {{
|
||||
background: palette(highlight);
|
||||
}}
|
||||
"""
|
||||
|
||||
self.tree.setStyleSheet(tree_style)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle mouse move events for hover tracking"""
|
||||
# Early return if not the tree viewport
|
||||
if obj != self.tree.viewport():
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.MouseMove:
|
||||
index = self.tree.indexAt(event.pos())
|
||||
if index.isValid():
|
||||
self.delegate.set_hovered_index(index)
|
||||
else:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.Leave:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def set_directory(self, directory):
|
||||
"""Set the scripts directory"""
|
||||
self.directory = directory
|
||||
|
||||
# Early return if directory doesn't exist
|
||||
if not directory or not os.path.exists(directory):
|
||||
return
|
||||
|
||||
root_index = self.model.setRootPath(directory)
|
||||
# Map the source model index to proxy model index
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
self.tree.setRootIndex(proxy_root_index)
|
||||
self.tree.expandAll()
|
||||
|
||||
def _on_item_clicked(self, index: QModelIndex):
|
||||
"""Handle item clicks"""
|
||||
# Map proxy index back to source index
|
||||
source_index = self.proxy_model.mapToSource(index)
|
||||
|
||||
# Early return for directories
|
||||
if self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
path_obj = Path(file_path)
|
||||
|
||||
# Only emit signal for Python files
|
||||
if path_obj.suffix.lower() == ".py":
|
||||
logger.info(f"Script selected: {file_path}")
|
||||
self.file_selected.emit(file_path)
|
||||
|
||||
def _on_item_double_clicked(self, index: QModelIndex):
|
||||
"""Handle item double-clicks"""
|
||||
# Map proxy index back to source index
|
||||
source_index = self.proxy_model.mapToSource(index)
|
||||
|
||||
# Early return for directories
|
||||
if self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
# Emit signal to open the file
|
||||
logger.info(f"File open requested via double-click: {file_path}")
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def _on_file_open_requested(self):
|
||||
"""Handle file open action triggered"""
|
||||
logger.info("File open requested")
|
||||
# Early return if no hovered item
|
||||
if not self.delegate.hovered_index.isValid():
|
||||
return
|
||||
|
||||
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for file items"""
|
||||
self.delegate.add_file_action(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directory items"""
|
||||
self.delegate.add_dir_action(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions from items"""
|
||||
self.delegate.clear_actions()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tree view"""
|
||||
if self.directory is None:
|
||||
return
|
||||
self.model.setRootPath("") # Reset
|
||||
root_index = self.model.setRootPath(self.directory)
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
self.tree.setRootIndex(proxy_root_index)
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all items in the tree"""
|
||||
self.tree.expandAll()
|
||||
|
||||
def collapse_all(self):
|
||||
"""Collapse all items in the tree"""
|
||||
self.tree.collapseAll()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{'files': ['main_window.py']}
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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()
|
||||
@@ -23,16 +23,24 @@ 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 = False
|
||||
PLUGIN = False
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
|
||||
|
||||
@@ -50,6 +58,14 @@ 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()
|
||||
|
||||
@@ -58,6 +74,34 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self.display_client_message, MessageEndpoints.client_info()
|
||||
)
|
||||
|
||||
def setCentralWidget(self, widget: QWidget, qt_default: bool = False): # type: ignore[override]
|
||||
"""
|
||||
Re‑implement 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
|
||||
################################################################################
|
||||
@@ -94,6 +138,26 @@ 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
|
||||
|
||||
@@ -293,7 +357,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
########################################
|
||||
# Theme menu
|
||||
theme_menu = menu_bar.addMenu("Theme")
|
||||
theme_menu = menu_bar.addMenu("View")
|
||||
|
||||
theme_group = QActionGroup(self)
|
||||
light_theme_action = QAction("Light Theme", self, checkable=True)
|
||||
@@ -310,11 +374,12 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
# Set the default theme
|
||||
theme = self.app.theme.theme
|
||||
if theme == "light":
|
||||
light_theme_action.setChecked(True)
|
||||
elif theme == "dark":
|
||||
dark_theme_action.setChecked(True)
|
||||
if hasattr(self.app, "theme") and self.app.theme:
|
||||
theme_name = self.app.theme.theme.lower()
|
||||
if "light" in theme_name:
|
||||
light_theme_action.setChecked(True)
|
||||
elif "dark" in theme_name:
|
||||
dark_theme_action.setChecked(True)
|
||||
|
||||
########################################
|
||||
# Help menu
|
||||
@@ -379,12 +444,12 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
@SafeSlot(str)
|
||||
def change_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
Change the theme of the application and propagate it to widgets.
|
||||
|
||||
Args:
|
||||
theme(str): The theme to apply, either "light" or "dark".
|
||||
theme(str): Either "light" or "dark".
|
||||
"""
|
||||
apply_theme(theme)
|
||||
apply_theme(theme) # emits theme_updated and applies palette globally
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
@@ -430,15 +495,16 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class UILaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
class BECMainWindowNoRPC(BECMainWindow):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
main_window = UILaunchWindow()
|
||||
main_window = BECMainWindow()
|
||||
main_window.show()
|
||||
main_window.resize(800, 600)
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.containers.main_window.bec_main_window_plugin import (
|
||||
BECMainWindowPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMainWindowPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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
|
||||
@@ -20,6 +21,8 @@ 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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "cancel"
|
||||
RPC = True
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -38,9 +38,6 @@ class AbortButton(BECWidget, QWidget):
|
||||
else:
|
||||
self.button = QPushButton()
|
||||
self.button.setText("Abort")
|
||||
self.button.setStyleSheet(
|
||||
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
self.button.clicked.connect(self.abort_scan)
|
||||
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
@@ -11,7 +11,7 @@ class ResetButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "restart_alt"
|
||||
RPC = True
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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
|
||||
@@ -20,6 +21,8 @@ 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
|
||||
|
||||
@@ -48,7 +51,7 @@ class ResetButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "ResetButton"
|
||||
|
||||
def toolTip(self):
|
||||
return "A button that reset the scan queue."
|
||||
return "A button that resets the scan queue."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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
|
||||
@@ -20,6 +21,8 @@ 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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "dangerous"
|
||||
RPC = True
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
@@ -31,9 +31,7 @@ class StopButton(BECWidget, QWidget):
|
||||
self.button = QPushButton()
|
||||
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.button.setText("Stop")
|
||||
self.button.setStyleSheet(
|
||||
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
self.button.setProperty("variant", "danger")
|
||||
self.button.clicked.connect(self.stop_scan)
|
||||
|
||||
self.layout.addWidget(self.button)
|
||||
|
||||
@@ -1,10 +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
|
||||
|
||||
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
|
||||
|
||||
@@ -15,8 +14,6 @@ DOM_XML = """
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class StopButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
@@ -24,6 +21,8 @@ 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
|
||||
|
||||
|
||||
@@ -1,10 +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
|
||||
|
||||
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,
|
||||
@@ -17,8 +16,6 @@ DOM_XML = """
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
@@ -26,6 +23,8 @@ 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
|
||||
|
||||
@@ -54,7 +53,7 @@ class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
|
||||
return "PositionIndicator"
|
||||
|
||||
def toolTip(self):
|
||||
return "PositionIndicator"
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -138,7 +138,11 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
signals = msg_content.get("signals", {})
|
||||
# pylint: disable=protected-access
|
||||
hinted_signals = self.dev[device]._hints
|
||||
precision = self.dev[device].precision
|
||||
precision = getattr(self.dev[device], "precision", 8)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = int(8)
|
||||
|
||||
spinner = ui_components["spinner"]
|
||||
position_indicator = ui_components["position_indicator"]
|
||||
@@ -178,11 +182,13 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||
spinner.setVisible(False)
|
||||
|
||||
if readback_val is not None:
|
||||
readback.setText(f"{readback_val:.{precision}f}")
|
||||
text = f"{readback_val:.{precision}f}"
|
||||
readback.setText(text)
|
||||
position_emit(readback_val)
|
||||
|
||||
if setpoint_val is not None:
|
||||
setpoint.setText(f"{setpoint_val:.{precision}f}")
|
||||
text = f"{setpoint_val:.{precision}f}"
|
||||
setpoint.setText(text)
|
||||
|
||||
limits = self.dev[device].limits
|
||||
limit_update(limits)
|
||||
@@ -205,10 +211,13 @@ 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 = self.dev[device].precision
|
||||
if precision is not None:
|
||||
ui["step_size"].setDecimals(precision)
|
||||
ui["step_size"].setValue(10**-precision * 10)
|
||||
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)
|
||||
|
||||
def _swap_readback_signal_connection(self, slot, old_device, new_device):
|
||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||
|
||||
@@ -12,7 +12,7 @@ from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
|
||||
USER_ACCESS = ["set_positioner"]
|
||||
USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
@@ -259,7 +259,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = PositionerBox(device="bpm4i")
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# 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 import PositionerBox
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box import (
|
||||
PositionerBox,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -14,7 +15,6 @@ DOM_XML = """
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
@@ -23,6 +23,8 @@ 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
|
||||
|
||||
@@ -30,7 +32,7 @@ class PositionerBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Device Control"
|
||||
return "BEC Device Control"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(PositionerBox.ICON_NAME)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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 (
|
||||
@@ -22,6 +23,8 @@ 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
|
||||
|
||||
@@ -29,7 +32,7 @@ class PositionerBox2DPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Device Control"
|
||||
return "BEC Device Control"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(PositionerBox2D.ICON_NAME)
|
||||
|
||||
@@ -13,7 +13,7 @@ from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QDoubleSpinBox
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||
@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "attach", "detach", "screenshot"]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
@@ -478,7 +478,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("dark")
|
||||
apply_theme("dark")
|
||||
widget = PositionerBox2D()
|
||||
|
||||
widget.show()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# 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 import PositionerControlLine
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
@@ -14,7 +15,6 @@ DOM_XML = """
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
@@ -23,6 +23,8 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = PositionerControlLine(parent)
|
||||
return t
|
||||
|
||||
@@ -30,7 +32,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Device Control"
|
||||
return "BEC Device Control"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(PositionerControlLine.ICON_NAME)
|
||||
@@ -51,7 +53,7 @@ class PositionerControlLinePlugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
return "PositionerControlLine"
|
||||
|
||||
def toolTip(self):
|
||||
return "A widget that controls a single positioner in line form."
|
||||
return "A widget that controls a single device."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class PositionerGroupBox(QGroupBox):
|
||||
PLUGIN = True
|
||||
|
||||
position_update = Signal(float)
|
||||
|
||||
def __init__(self, parent, dev_name):
|
||||
@@ -45,7 +45,12 @@ class PositionerGroupBox(QGroupBox):
|
||||
|
||||
def _on_position_update(self, pos: float):
|
||||
self.position_update.emit(pos)
|
||||
self.widget.label = f"%.{self.widget.dev[self.widget.device].precision}f" % 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}"
|
||||
|
||||
def close(self):
|
||||
self.widget.close()
|
||||
@@ -55,8 +60,9 @@ 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"]
|
||||
USER_ACCESS = ["set_positioners", "attach", "detach", "screenshot"]
|
||||
|
||||
# Signal emitted to inform listeners about a position update of the first positioner
|
||||
position_update = Signal(float)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{'files': ['positioner_group.py']}
|
||||
{'files': ['positioner_group.py']}
|
||||
@@ -1,9 +1,8 @@
|
||||
# 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 (
|
||||
@@ -16,7 +15,6 @@ DOM_XML = """
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
class PositionerGroupPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
@@ -25,6 +23,8 @@ 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 "Device Control"
|
||||
return "BEC 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 "Container Widget to control positioners in compact form, in a grid"
|
||||
return "Simple Widget to control a positioner in box form"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user