mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 09:47:52 +02:00
Compare commits
1 Commits
feat/proce
...
config_plo
| Author | SHA1 | Date | |
|---|---|---|---|
| a523e66536 |
@@ -1,3 +1,2 @@
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
isort --line-length=100 --profile=black --multi-line=3 --trailing-comma $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
|
||||
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Bug report
|
||||
description: File a bug report.
|
||||
title: "[BUG]: "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Bug report:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Provide a brief description of the bug.
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Describe what you expected to happen and what actually happened.
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: bec_widgets version
|
||||
description: which version of BEC widgets was running?
|
||||
- type: input
|
||||
id: bec-version
|
||||
attributes:
|
||||
label: bec core version
|
||||
description: which version of BEC core was running?
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Any extra info / data? e.g. log output...
|
||||
- type: input
|
||||
id: issues
|
||||
attributes:
|
||||
label: Related issues
|
||||
description: please tag any related issues
|
||||
37
.github/ISSUE_TEMPLATE/documentation_update.md
vendored
37
.github/ISSUE_TEMPLATE/documentation_update.md
vendored
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Documentation update request
|
||||
about: Suggest an update to the docs
|
||||
title: '[DOCS]: '
|
||||
type: documentation
|
||||
label: documentation
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Documentation Section
|
||||
|
||||
[Specify the section or page of the documentation that needs updating]
|
||||
|
||||
## Current Information
|
||||
|
||||
[Provide the current information in the documentation that needs to be updated]
|
||||
|
||||
## Proposed Update
|
||||
|
||||
[Describe the proposed update or correction. Be specific about the changes that need to be made]
|
||||
|
||||
## Reason for Update
|
||||
|
||||
[Explain the reason for the documentation update. Include any recent changes, new features, or corrections that necessitate the update]
|
||||
|
||||
## Additional Context
|
||||
|
||||
[Include any additional context or information that can help the documentation team understand the update better]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Attach any files, screenshots, or references that can assist in making the documentation update]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the documentation update based on its urgency. Use a scale such as Low, Medium, High]
|
||||
49
.github/ISSUE_TEMPLATE/feature_request.md
vendored
49
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FEAT]: '
|
||||
type: feature
|
||||
label: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
|
||||
## Problem Description
|
||||
|
||||
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
|
||||
|
||||
## Use Case
|
||||
|
||||
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
|
||||
|
||||
## Benefits
|
||||
|
||||
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
|
||||
|
||||
## Impact on Existing Functionality
|
||||
|
||||
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
|
||||
|
||||
## Additional Information
|
||||
|
||||
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
|
||||
65
.github/actions/bw_install/action.yml
vendored
65
.github/actions/bw_install/action.yml
vendored
@@ -1,65 +0,0 @@
|
||||
name: "BEC Widgets Install"
|
||||
description: "Install BEC Widgets and related os dependencies"
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of BEC Widgets to install"
|
||||
BEC_CORE_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of BEC Core to install"
|
||||
OPHYD_DEVICES_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
PYTHON_VERSION: # id of input
|
||||
required: false
|
||||
default: "3.11"
|
||||
description: "Python version to use"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.PYTHON_VERSION }}
|
||||
|
||||
- name: Checkout BEC Core
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
path: ./bec
|
||||
|
||||
- name: Checkout Ophyd Devices
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/ophyd_devices
|
||||
ref: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
path: ./ophyd_devices
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
path: ./bec_widgets
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
sudo apt-get -y install ttyd
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system -e ./ophyd_devices
|
||||
uv pip install --system -e ./bec/bec_lib[dev]
|
||||
uv pip install --system -e ./bec/bec_ipython_client
|
||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,33 +0,0 @@
|
||||
## Description
|
||||
|
||||
[Provide a brief description of the changes introduced by this pull request.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Cite any related issues or feature requests that are addressed or resolved by this pull request. Link the associated issue, for example, with `fixes #123` or `closes #123`.]
|
||||
|
||||
## Type of Change
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## How to test
|
||||
|
||||
- Run unit tests
|
||||
- Open [widget] in designer and play around with the properties
|
||||
|
||||
## Potential side effects
|
||||
|
||||
[Describe any potential side effects or risks of merging this PR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
[Include any relevant screenshots or GIFs to showcase the changes made.]
|
||||
|
||||
## Additional Comments
|
||||
|
||||
[Add any additional comments or information that may be helpful for reviewers.]
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Documentation is up-to-date.
|
||||
|
||||
28
.github/workflows/check_pr.yml
vendored
28
.github/workflows/check_pr.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Check PR status for branch
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
branch-pr:
|
||||
description: The PR number if the branch is in one
|
||||
value: ${{ jobs.pr.outputs.branch-pr }}
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
runs-on: "ubuntu-latest"
|
||||
outputs:
|
||||
branch-pr: ${{ steps.script.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
id: script
|
||||
if: github.event_name == 'push' && github.event.ref_type != 'tag'
|
||||
with:
|
||||
script: |
|
||||
const prs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
head: context.repo.owner + ':${{ github.ref_name }}'
|
||||
})
|
||||
if (prs.data.length) {
|
||||
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
|
||||
return prs.data[0]["number"]
|
||||
}
|
||||
64
.github/workflows/child_repos.yml
vendored
64
.github/workflows/child_repos.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch for BEC Core'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch for Ophyd Devices'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch for BEC Widgets'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
|
||||
jobs:
|
||||
bec:
|
||||
name: BEC Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Install BEC and dependencies
|
||||
uses: ./.github/actions/bec_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
cd ./bec
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/bec_e2e_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
84
.github/workflows/ci.yml
vendored
84
.github/workflows/ci.yml
vendored
@@ -1,84 +0,0 @@
|
||||
name: Full CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check_pr_status:
|
||||
uses: ./.github/workflows/check_pr.yml
|
||||
|
||||
formatter:
|
||||
needs: check_pr_status
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/formatter.yml
|
||||
|
||||
unit-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
unit-test-matrix:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/generate-cli-check.yml
|
||||
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
|
||||
child-repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
59
.github/workflows/end2end-conda.yml
vendored
59
.github/workflows/end2end-conda.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Run Pytest with Coverage
|
||||
on: [workflow_call]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Conda
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
auto-update-conda: true
|
||||
auto-activate-base: true
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
sudo apt-get -y install ttyd
|
||||
|
||||
- name: Conda install and run pytest
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
|
||||
cd ./bec
|
||||
conda create -q -n test-environment python=3.11
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
66
.github/workflows/formatter.yml
vendored
66
.github/workflows/formatter.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: Formatter and Pylint jobs
|
||||
on: [workflow_call]
|
||||
jobs:
|
||||
|
||||
Formatter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Run black and isort
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system black isort
|
||||
uv pip install --system -e .[dev]
|
||||
black --check --diff --color .
|
||||
isort --check --diff ./
|
||||
|
||||
- name: Check for disallowed imports from PySide
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
|
||||
Pylint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pylint pylint-exit anybadge
|
||||
|
||||
- name: Run Pylint
|
||||
run: |
|
||||
mkdir -p ./pylint
|
||||
set +e
|
||||
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
|
||||
pylint-exit $?
|
||||
set -e
|
||||
|
||||
- name: Extract Pylint Score
|
||||
id: score
|
||||
run: |
|
||||
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Badge
|
||||
run: |
|
||||
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pylint-artifacts
|
||||
path: |
|
||||
# ./pylint/pylint.log # not sure why this isn't working
|
||||
./pylint/pylint.svg
|
||||
49
.github/workflows/generate-cli-check.yml
vendored
49
.github/workflows/generate-cli-check.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Run bw-generate-cli
|
||||
on: [workflow_call]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install os dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/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://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e ./bec/bec_lib[dev]
|
||||
pip install -e ./bec/bec_ipython_client
|
||||
pip install -e .[dev,pyside6]
|
||||
|
||||
- name: Run bw-generate-cli
|
||||
run: |
|
||||
bw-generate-cli --target bec_widgets
|
||||
git diff --exit-code
|
||||
|
||||
59
.github/workflows/pytest-matrix.yml
vendored
59
.github/workflows/pytest-matrix.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Run Pytest with different Python versions
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
pytest-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
env:
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
72
.github/workflows/pytest.yml
vendored
72
.github/workflows/pytest.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
- name: Run Pytest with Coverage
|
||||
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:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: bec-project/bec_widgets
|
||||
103
.github/workflows/semantic_release.yml
vendored
103
.github/workflows/semantic_release.yml
vendored
@@ -1,103 +0,0 @@
|
||||
name: Continuous Delivery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# default: least privileged permissions across all jobs
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
# Note: We checkout the repository at the branch that triggered the workflow
|
||||
# with the entire history to ensure to match PSR's release branch detection
|
||||
# and history evaluation.
|
||||
# However, we forcefully reset the branch to the workflow sha because it is
|
||||
# possible that the branch was updated while the workflow was running. This
|
||||
# prevents accidentally releasing un-evaluated changes.
|
||||
- name: Setup | Checkout Repository on Release Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
|
||||
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup | Force release branch to be at workflow sha
|
||||
run: |
|
||||
git reset --hard ${{ github.sha }}
|
||||
- name: Evaluate | Verify upstream has NOT changed
|
||||
# Last chance to abort before causing an error as another PR/push was applied to
|
||||
# the upstream branch while this workflow was running. This is important
|
||||
# because we are committing a version change (--commit). You may omit this step
|
||||
# if you have 'commit: false' in your configuration.
|
||||
#
|
||||
# You may consider moving this to a repo script and call it from this step instead
|
||||
# of writing it in-line.
|
||||
shell: bash
|
||||
run: |
|
||||
set +o pipefail
|
||||
|
||||
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
|
||||
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
|
||||
|
||||
set -o pipefail
|
||||
|
||||
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
|
||||
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
|
||||
|
||||
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
|
||||
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
|
||||
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
|
||||
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
|
||||
|
||||
- name: Semantic Version Release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
pip install python-semantic-release==9.* wheel build twine
|
||||
semantic-release -vv version
|
||||
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
|
||||
semantic-release publish
|
||||
19
.github/workflows/stale-issues.yml
vendored
19
.github/workflows/stale-issues.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '00 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
days-before-stale: 120
|
||||
days-before-close: 14
|
||||
35
.github/workflows/sync-issues-pr.yml
vendored
35
.github/workflows/sync-issues-pr.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: Sync PR to Project
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
[
|
||||
opened,
|
||||
assigned,
|
||||
unassigned,
|
||||
edited,
|
||||
ready_for_review,
|
||||
converted_to_draft,
|
||||
reopened,
|
||||
synchronize,
|
||||
closed,
|
||||
]
|
||||
|
||||
jobs:
|
||||
sync-project:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Sync PR to Project
|
||||
uses: bec-project/action-issue-sync-pr@v1
|
||||
with:
|
||||
token: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
|
||||
org: ${{ github.repository_owner }}
|
||||
repo: ${{ github.event.repository.name }}
|
||||
project-number: 3
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -64,9 +64,6 @@ coverage.xml
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Output from end2end testing
|
||||
tests/reference_failures/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
97
.gitlab-ci.yml
Normal file
97
.gitlab-ci.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
# 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_DOCKER_REGISTRY/python:3.9
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- Unittests
|
||||
- Deploy
|
||||
|
||||
formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install black
|
||||
- black --check --diff --color --line-length=100 ./
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
- 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
|
||||
|
||||
tests:
|
||||
stage: Unittests
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx x11-utils libxkbcommon-x11-0
|
||||
- pip install .[dev]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
cobertura: coverage.xml
|
||||
|
||||
|
||||
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
|
||||
- pip install python-semantic-release wheel
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- export REPOSITORY_USERNAME=__token__
|
||||
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
|
||||
- >
|
||||
semantic-release publish -v DEBUG
|
||||
-D version_variable=./setup.py:__version__
|
||||
-D hvcs=gitlab
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
|
||||
# pages:
|
||||
# stage: Deploy
|
||||
# needs: ["tests"]
|
||||
# script:
|
||||
# - git clone --branch $OPHYD_DEVICES_BRANCH https://oauth2:$CI_OPHYD_DEVICES_KEY@gitlab.psi.ch/bec/ophyd_devices.git
|
||||
# - export OPHYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
# - pip install -r ./docs/source/requirements.txt
|
||||
# - apt-get install -y gcc
|
||||
# - *install-bec-services
|
||||
# - cd ./docs/source; make html
|
||||
# - curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/beamline-experiment-control/221870/
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
# - if: '$CI_COMMIT_REF_NAME == "production"'
|
||||
581
.pylintrc
581
.pylintrc
@@ -1,581 +0,0 @@
|
||||
[MASTER]
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
|
||||
# Specify a score threshold to be exceeded before program exits with error.
|
||||
fail-under=8.0
|
||||
|
||||
# Files or directories to be skipped. They should be base names, not paths.
|
||||
ignore=CVS
|
||||
|
||||
# Add files or directories matching the regex patterns to the ignore-list. The
|
||||
# regex matches against paths and can be in Posix or Windows format.
|
||||
ignore-paths=
|
||||
|
||||
# Files or directories matching the regex patterns are skipped. The regex
|
||||
# matches against base names, not paths.
|
||||
ignore-patterns=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use.
|
||||
jobs=1
|
||||
|
||||
# Control the amount of potential inferred values when inferring a single
|
||||
# object. This can help the performance when dealing with large functions or
|
||||
# complex, nested conditions.
|
||||
limit-inference-results=100
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.11
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
|
||||
confidence=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once). You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
||||
# --disable=W".
|
||||
disable=missing-module-docstring,
|
||||
missing-class-docstring,
|
||||
import-error,
|
||||
no-name-in-module,
|
||||
raw-checker-failed,
|
||||
bad-inline-option,
|
||||
locally-disabled,
|
||||
file-ignored,
|
||||
suppressed-message,
|
||||
deprecated-pragma,
|
||||
use-symbolic-message-instead,
|
||||
unused-wildcard-import,
|
||||
logging-fstring-interpolation,
|
||||
line-too-long,
|
||||
too-many-instance-attributes,
|
||||
wrong-import-order
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a score less than or equal to 10. You
|
||||
# have access to the variables 'error', 'warning', 'refactor', and 'convention'
|
||||
# which contain the number of messages in each category, as well as 'statement'
|
||||
# which is the total number of statements analyzed. This score is used by the
|
||||
# global evaluation report (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details.
|
||||
#msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio). You can also give a reporter class, e.g.
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Tells whether to display a full report or only the messages.
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# The type of string formatting that logging methods do. `old` means using %
|
||||
# formatting, `new` is for `{}` formatting.
|
||||
logging-format-style=old
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format.
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes.
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: none. To make it work,
|
||||
# install the 'python-enchant' package.
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should be considered directives if they
|
||||
# appear and the beginning of a comment and should not be checked.
|
||||
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains the private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to the private dictionary (see the
|
||||
# --spelling-private-dict-file option) instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
# Regular expression of note tags to take in consideration.
|
||||
#notes-rgx=
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# class is considered mixin if its name matches the mixin-class-rgx option.
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# Tells whether to warn about missing members when the owner of the attribute
|
||||
# is inferred to be None.
|
||||
ignore-none=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis). It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
# Regex pattern to define which classes are considered mixins ignore-mixin-
|
||||
# members is set to 'yes'
|
||||
mixin-class-rgx=.*[Mm]ixin
|
||||
|
||||
# List of decorators that change the signature of a decorated function.
|
||||
signature-mutators=
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid defining new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of names allowed to shadow builtins
|
||||
allowed-redefined-builtins=
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
||||
# not be used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore.
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module.
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Comments are removed from the similarity computation
|
||||
ignore-comments=yes
|
||||
|
||||
# Docstrings are removed from the similarity computation
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Imports are removed from the similarity computation
|
||||
ignore-imports=no
|
||||
|
||||
# Signatures are removed from the similarity computation
|
||||
ignore-signatures=no
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=4
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names.
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style.
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names.
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style.
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma.
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be refused
|
||||
bad-names-rgxs=
|
||||
|
||||
# Naming style matching correct class attribute names.
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style.
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class constant names.
|
||||
class-const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct class constant names. Overrides class-
|
||||
# const-naming-style.
|
||||
#class-const-rgx=
|
||||
|
||||
# Naming style matching correct class names.
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-
|
||||
# style.
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names.
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style.
|
||||
#const-rgx=
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names.
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style.
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
ii,
|
||||
jj,
|
||||
kk,
|
||||
dr,
|
||||
j,
|
||||
k,
|
||||
ex,
|
||||
Run,
|
||||
cb,
|
||||
_
|
||||
|
||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be accepted
|
||||
good-names-rgxs=.*scanID.*,.*RID.*,.*pointID.*,.*ID.*,.*_2D.*,.*_1D.*
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name.
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names.
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style.
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names.
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style.
|
||||
#method-rgx=
|
||||
|
||||
# Naming style matching correct module names.
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style.
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
# These decorators are taken in consideration only for invalid-name.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Naming style matching correct variable names.
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style.
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[STRING]
|
||||
|
||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
||||
# character used as a quote delimiter is used inconsistently within a module.
|
||||
check-quote-consistency=no
|
||||
|
||||
# This flag controls whether the implicit-str-concat should generate a warning
|
||||
# on implicit string concatenation in sequences defined over several lines.
|
||||
check-str-concat-over-line-jumps=no
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# List of modules that can be imported at any level, not just the top level
|
||||
# one.
|
||||
allow-any-import-level=
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma.
|
||||
deprecated-modules=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of external dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
ext-import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of all (i.e. internal and
|
||||
# external) dependencies to the given file (report RP0402 must not be
|
||||
# disabled).
|
||||
import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of internal dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
# Couples of modules and preferred modules, separated by a comma.
|
||||
preferred-modules=
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# Warn about protected attribute access inside special methods
|
||||
check-protected-access-in-special-methods=no
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp,
|
||||
__post_init__
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,
|
||||
_fields,
|
||||
_replace,
|
||||
_source,
|
||||
_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=cls
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# List of regular expressions of class ancestor names to ignore when counting
|
||||
# public methods (see R0903)
|
||||
exclude-too-few-public-methods=
|
||||
|
||||
# List of qualified class names to ignore when counting class parents (see
|
||||
# R0901)
|
||||
ignored-parents=
|
||||
|
||||
# Maximum number of arguments for function / method.
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body.
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body.
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body.
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body.
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "BaseException, Exception".
|
||||
overgeneral-exceptions=BaseException,
|
||||
Exception
|
||||
@@ -1,27 +0,0 @@
|
||||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||
# formats:
|
||||
# - pdf
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
path: .[dev]
|
||||
9712
CHANGELOG.md
9712
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
29
LICENSE
29
LICENSE
@@ -1,29 +0,0 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, Paul Scherrer Institute
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
198
README.md
198
README.md
@@ -1,200 +1,2 @@
|
||||

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
|
||||
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
|
||||
plugin system.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
|
||||
a single line of code**. Just launch, drag widgets, and do your experiment.
|
||||
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: drag‑dock, tab,
|
||||
split, and export profiles/workspaces for reuse.
|
||||
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
|
||||
`BECIPythonClient`.
|
||||
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
|
||||
with the custom BEC loader for a zero‑glue workflow.
|
||||
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
|
||||
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
|
||||
acquisition history.
|
||||
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
|
||||
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
|
||||
or override behavior as needed.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Features](#features)
|
||||
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
|
||||
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
|
||||
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
|
||||
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
|
||||
- [Widget Library](#widget-library)
|
||||
- [Documentation](#documentation)
|
||||
- [License](#license)
|
||||
|
||||
## Installation
|
||||
|
||||
Use any of the following setups:
|
||||
|
||||
### Stable release
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets
|
||||
```
|
||||
|
||||
### From source (recommended for development)
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bec-project/bec_widgets.git
|
||||
cd bec_widgets
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Dock area interface: build GUIs in seconds
|
||||
|
||||
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
|
||||
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
|
||||
operate immediately and refine later with RPC or Designer if needed.
|
||||
|
||||

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

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

|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
|
||||
Designer plugin that are immediately usable with your BEC setup. Widgets
|
||||
come online with live BEC/Redis wiring out of the box. 
|
||||
|
||||
<details>
|
||||
<summary> View code: Example Widget </summary>
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
|
||||
class SimpleMotorWidget(BECWidget, QWidget):
|
||||
USER_ACCESS = ["move"]
|
||||
|
||||
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.motor_name = motor_name
|
||||
self.step = float(step)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.value_label = QLabel(f"{self.motor_name}: —")
|
||||
self.btn_left = QPushButton("◀︎ -5")
|
||||
self.btn_right = QPushButton("+5 ▶︎")
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.btn_left)
|
||||
row.addWidget(self.btn_right)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.addWidget(self.value_label)
|
||||
col.addLayout(row)
|
||||
|
||||
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
|
||||
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_readback(self, data: dict, meta: dict):
|
||||
current_value = data.get("signals").get(self.motor_name).get('value')
|
||||
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
|
||||
|
||||
@Slot(str, float)
|
||||
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
|
||||
if direction == "left":
|
||||
self.dev[self.motor_name].move(-step, relative=True)
|
||||
else:
|
||||
self.dev[self.motor_name].move(step, relative=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = SimpleMotorWidget(motor_name="samx", step=5.0)
|
||||
w.setWindowTitle("MotorJogWidget")
|
||||
w.resize(280, 90)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Widget Library
|
||||
|
||||
A large and growing catalog—plug, configure, run:
|
||||
|
||||
### Plotting
|
||||
|
||||
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
|
||||
history data.
|
||||
|
||||
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
|
||||
|
||||
### Scan orchestration and motion control.
|
||||
|
||||
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
|
||||
Positioner boxes and tweak controls handle precise moves, homing, and calibration for day‑to‑day alignment.
|
||||
|
||||
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
|
||||
|
||||
Core Dependencies:
|
||||
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
|
||||
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
|
||||
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
|
||||
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
|
||||
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
|
||||
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
|
||||
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
|
||||
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
|
||||
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
|
||||
|
||||
|
||||
Additional Dependencies (Testing/Development):
|
||||
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
|
||||
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
|
||||
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
|
||||
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
|
||||
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
|
||||
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
|
||||
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
|
||||
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
|
||||
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
|
||||
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads 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"]
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def dock_area(
|
||||
object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None
|
||||
) -> BECDockArea:
|
||||
"""
|
||||
Create an advanced dock area using Qt Advanced Docking System.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the advanced dock area.
|
||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
||||
the workspace:
|
||||
- None: start empty
|
||||
- "restore": restore last used profile
|
||||
- "skip": do not initialize profile state
|
||||
- "<name>": load specific profile
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created advanced dock area.
|
||||
|
||||
"""
|
||||
|
||||
widget = BECDockArea(
|
||||
object_name=object_name,
|
||||
root_widget=True,
|
||||
profile_namespace="bec",
|
||||
startup_profile=startup_profile,
|
||||
)
|
||||
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
|
||||
return widget
|
||||
|
||||
|
||||
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
|
||||
"""
|
||||
Create a dock area with auto update enabled.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the dock area.
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created dock area.
|
||||
"""
|
||||
_auto_update = AutoUpdates(object_name=object_name)
|
||||
return _auto_update
|
||||
@@ -1,716 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
|
||||
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_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import get_last_profile, list_profiles
|
||||
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
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)"
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
DEFAULT_SIZE = (250, 300)
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None = None,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
tile_size: tuple[int, int] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, orientation="vertical")
|
||||
|
||||
# Provide a per‑instance TILE_SIZE so the class can compute layout
|
||||
if tile_size is None:
|
||||
tile_size = self.DEFAULT_SIZE
|
||||
self.tile_size = tile_size
|
||||
|
||||
self.icon_label = QLabel(parent=self)
|
||||
self.icon_label.setFixedSize(100, 100)
|
||||
self.icon_label.setScaledContents(True)
|
||||
pixmap = QPixmap(icon_path)
|
||||
if not pixmap.isNull():
|
||||
size = 100
|
||||
circular_pixmap = QPixmap(size, size)
|
||||
circular_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(circular_pixmap)
|
||||
painter.setRenderHints(QPainter.RenderHint.Antialiasing, True)
|
||||
path = QPainterPath()
|
||||
path.addEllipse(0, 0, size, size)
|
||||
painter.setClipPath(path)
|
||||
pixmap = pixmap.scaled(
|
||||
size,
|
||||
size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
|
||||
self.icon_label.setPixmap(circular_pixmap)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Top label
|
||||
self.top_label = QLabel(top_label.upper())
|
||||
font_top = self.top_label.font()
|
||||
font_top.setPointSize(10)
|
||||
self.top_label.setFont(font_top)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
|
||||
# Desired default appearance
|
||||
font_main = self.main_label.font()
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
self.tile_size[0]
|
||||
- self.layout.contentsMargins().left()
|
||||
- self.layout.contentsMargins().right()
|
||||
)
|
||||
self._fit_label_to_width(self.main_label, content_width)
|
||||
|
||||
# Give every tile the same reserved height for the title so the
|
||||
# description labels start at an identical y‑offset.
|
||||
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
|
||||
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.layout.addItem(self.spacer_top)
|
||||
|
||||
# Description
|
||||
self.description_label = QLabel(description)
|
||||
self.description_label.setWordWrap(True)
|
||||
self.description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.layout.addWidget(self.description_label)
|
||||
|
||||
# Selector
|
||||
if show_selector:
|
||||
self.selector = QComboBox(self)
|
||||
self.layout.addWidget(self.selector)
|
||||
else:
|
||||
self.selector = None
|
||||
|
||||
self.spacer_bottom = QSpacerItem(
|
||||
0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
self.layout.addItem(self.spacer_bottom)
|
||||
|
||||
# Action button
|
||||
self.action_button = QPushButton("Open")
|
||||
self.action_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #007AFF;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #005BB5;
|
||||
}
|
||||
""")
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
Fit the label text to the specified maximum width by adjusting the font size.
|
||||
|
||||
Args:
|
||||
label(QLabel): The label to adjust.
|
||||
max_width(int): The maximum width the label can occupy.
|
||||
min_pt(int): The minimum font point size to use.
|
||||
"""
|
||||
font = label.font()
|
||||
for pt in range(font.pointSize(), min_pt - 1, -1):
|
||||
font.setPointSize(pt)
|
||||
metrics = QFontMetrics(font)
|
||||
if metrics.horizontalAdvance(label.text()) <= max_width:
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
return
|
||||
# If nothing fits, fall back to eliding
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.TextElideMode.ElideRight, max_width))
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
TILE_SIZE = (250, 300)
|
||||
DEFAULT_LAUNCH_SIZE = (800, 600)
|
||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
gui_id: str = None,
|
||||
window_title="BEC Launcher",
|
||||
launch_gui_class: str = None,
|
||||
launch_gui_id: str = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
# Track the smallest main‑label font size chosen so far
|
||||
self._min_main_label_pt: int | None = None
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget(self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
# Main Widget
|
||||
self.central_widget = QWidget(self)
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.register_tile(
|
||||
name="dock_area",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
top_label="Get started",
|
||||
main_label="BEC Advanced Dock Area",
|
||||
description="Flexible application for managing modular widgets and user profiles.",
|
||||
action_button=self._open_dock_area,
|
||||
show_selector=True,
|
||||
selector_items=list_profiles("bec"),
|
||||
)
|
||||
self._refresh_dock_area_profiles(preserve_selection=False)
|
||||
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
self.register_tile(
|
||||
name="auto_update",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
|
||||
top_label="Get automated",
|
||||
main_label="BEC Auto Update Dock Area",
|
||||
description="Dock area with auto update functionality for BEC widgets plotting.",
|
||||
action_button=self._open_auto_update,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
|
||||
)
|
||||
|
||||
self.register_tile(
|
||||
name="custom_ui_file",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
|
||||
top_label="Get customized",
|
||||
main_label="Launch Custom UI File",
|
||||
description="GUI application with custom UI file.",
|
||||
action_button=self._open_custom_ui_file,
|
||||
show_selector=False,
|
||||
)
|
||||
|
||||
# plugin widgets
|
||||
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
|
||||
if self.available_widgets:
|
||||
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
|
||||
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
|
||||
self.register_tile(
|
||||
name="widget",
|
||||
icon_path=os.path.join(
|
||||
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
|
||||
),
|
||||
top_label="Get quickly started",
|
||||
main_label=f"Launch a {plugin_repo_name} Widget",
|
||||
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
|
||||
action_button=self._open_widget,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_widgets.keys()),
|
||||
)
|
||||
|
||||
self._update_theme()
|
||||
|
||||
self.register = RPCRegister()
|
||||
self.register.callbacks.append(self._turn_off_the_lights)
|
||||
self.register.broadcast()
|
||||
|
||||
if launch_gui_class and launch_gui_id:
|
||||
# If a specific gui class is provided, launch it and hide the launcher
|
||||
self.launch(launch_gui_class, name=launch_gui_id)
|
||||
self.hide()
|
||||
|
||||
def register_tile(
|
||||
self,
|
||||
name: str,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
action_button: Callable | None = None,
|
||||
show_selector: bool = False,
|
||||
selector_items: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Register a tile in the launcher window.
|
||||
|
||||
Args:
|
||||
name(str): The name of the tile.
|
||||
icon_path(str): The path to the icon.
|
||||
top_label(str): The top label of the tile.
|
||||
main_label(str): The main label of the tile.
|
||||
description(str): The description of the tile.
|
||||
action_button(callable): The action to be performed when the button is clicked.
|
||||
show_selector(bool): Whether to show a selector or not.
|
||||
selector_items(list[str]): The items to be shown in the selector.
|
||||
"""
|
||||
|
||||
tile = LaunchTile(
|
||||
icon_path=icon_path,
|
||||
top_label=top_label,
|
||||
main_label=main_label,
|
||||
description=description,
|
||||
show_selector=show_selector,
|
||||
tile_size=self.TILE_SIZE,
|
||||
)
|
||||
tile.setFixedWidth(self.TILE_SIZE[0])
|
||||
tile.setMinimumHeight(self.TILE_SIZE[1])
|
||||
tile.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.MinimumExpanding)
|
||||
if action_button:
|
||||
tile.action_button.clicked.connect(action_button)
|
||||
if show_selector and selector_items:
|
||||
tile.selector.addItems(selector_items)
|
||||
self.central_widget.layout.addWidget(tile)
|
||||
|
||||
# keep all tiles' main labels at a unified point size
|
||||
current_pt = tile.main_label.font().pointSize()
|
||||
if self._min_main_label_pt is None or current_pt < self._min_main_label_pt:
|
||||
# New global minimum – shrink every existing tile to this size
|
||||
self._min_main_label_pt = current_pt
|
||||
for t in self.tiles.values():
|
||||
f = t.main_label.font()
|
||||
f.setPointSize(self._min_main_label_pt)
|
||||
t.main_label.setFont(f)
|
||||
t.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
|
||||
elif current_pt > self._min_main_label_pt:
|
||||
# Tile is larger than global minimum – shrink it to match
|
||||
f = tile.main_label.font()
|
||||
f.setPointSize(self._min_main_label_pt)
|
||||
tile.main_label.setFont(f)
|
||||
tile.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
|
||||
|
||||
self.tiles[name] = tile
|
||||
|
||||
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
|
||||
"""
|
||||
Refresh the dock-area profile selector, optionally preserving the selection.
|
||||
Defaults to Start Empty when no valid selection can be preserved.
|
||||
|
||||
Args:
|
||||
preserve_selection(bool): Whether to preserve the current selection or not.
|
||||
"""
|
||||
tile = self.tiles.get("dock_area")
|
||||
if tile is None or tile.selector is None:
|
||||
return
|
||||
|
||||
selector = tile.selector
|
||||
selected_text = (
|
||||
selector.currentText().strip() if preserve_selection and selector.count() > 0 else ""
|
||||
)
|
||||
|
||||
profiles = list_profiles("bec")
|
||||
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
|
||||
selector.blockSignals(True)
|
||||
selector.clear()
|
||||
for profile in selector_items:
|
||||
selector.addItem(profile)
|
||||
|
||||
if selected_text:
|
||||
# Try to preserve the current selection
|
||||
idx = selector.findText(selected_text, Qt.MatchFlag.MatchExactly)
|
||||
if idx >= 0:
|
||||
selector.setCurrentIndex(idx)
|
||||
else:
|
||||
# Selection no longer exists, fall back to default startup selection.
|
||||
self._set_selector_to_default_profile(selector, profiles)
|
||||
else:
|
||||
# No selection to preserve, use default startup selection.
|
||||
self._set_selector_to_default_profile(selector, profiles)
|
||||
selector.blockSignals(False)
|
||||
|
||||
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
|
||||
"""
|
||||
Set the selector default.
|
||||
|
||||
Preference order:
|
||||
1) Start Empty option (if available)
|
||||
2) Last used profile
|
||||
3) First available profile
|
||||
|
||||
Args:
|
||||
selector(QComboBox): The combobox to set.
|
||||
profiles(list[str]): List of available profiles.
|
||||
"""
|
||||
start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly)
|
||||
if start_empty_idx >= 0:
|
||||
selector.setCurrentIndex(start_empty_idx)
|
||||
return
|
||||
|
||||
# Try to get last used profile
|
||||
last_profile = get_last_profile(namespace="bec")
|
||||
if last_profile and last_profile in profiles:
|
||||
idx = selector.findText(last_profile, Qt.MatchFlag.MatchExactly)
|
||||
if idx >= 0:
|
||||
selector.setCurrentIndex(idx)
|
||||
return
|
||||
|
||||
# If nothing else, select first item
|
||||
if selector.count() > 0:
|
||||
selector.setCurrentIndex(0)
|
||||
|
||||
def launch(
|
||||
self,
|
||||
launch_script: str,
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
**kwargs,
|
||||
) -> QWidget | None:
|
||||
"""Launch the specified script. If the launch script creates a QWidget, it will be
|
||||
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
|
||||
as a separate window.
|
||||
|
||||
Args:
|
||||
launch_script(str): The name of the script to be launched.
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
QWidget: The created dock area.
|
||||
"""
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
if geometry is None and launch_script != "custom_ui_file":
|
||||
geometry = self._default_launch_geometry()
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
# If name already exists, generate a unique one with counter suffix
|
||||
if name in existing_dock_areas:
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
|
||||
if launch_script is None:
|
||||
launch_script = "dock_area"
|
||||
if not isinstance(launch_script, str):
|
||||
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
|
||||
|
||||
if launch_script == "custom_ui_file":
|
||||
ui_file = kwargs.pop("ui_file", None)
|
||||
if not ui_file:
|
||||
return None
|
||||
return self._launch_custom_ui_file(ui_file)
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update, geometry=geometry)
|
||||
|
||||
if launch_script == "widget":
|
||||
widget = kwargs.pop("widget", None)
|
||||
if widget is None:
|
||||
raise ValueError("Widget name must be provided.")
|
||||
return self._launch_widget(widget, geometry=geometry)
|
||||
|
||||
launch = getattr(bw_launch, launch_script, None)
|
||||
if launch is None:
|
||||
raise ValueError(f"Launch script {launch_script} not found.")
|
||||
|
||||
result_widget = launch(name, **kwargs)
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
apply_window_geometry(result_widget, geometry)
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
|
||||
"""
|
||||
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
|
||||
instantiate it directly; otherwise, embed it in a UILaunchWindow.
|
||||
"""
|
||||
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
|
||||
widget = root.find("widget")
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
# Load the UI into a widget
|
||||
loader = UILoader(None)
|
||||
loaded = loader.loader(ui_file)
|
||||
|
||||
# Display the UI in a BECMainWindow
|
||||
if isinstance(loaded, BECMainWindow):
|
||||
window = loaded
|
||||
window.object_name = filename
|
||||
else:
|
||||
window = BECMainWindow(object_name=filename)
|
||||
window.setCentralWidget(loaded)
|
||||
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
apply_window_geometry(window, None)
|
||||
window.show()
|
||||
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(
|
||||
self, auto_update: str, geometry: tuple[int, int, int, int] | None = None
|
||||
) -> AutoUpdates:
|
||||
if auto_update in self.available_auto_updates:
|
||||
auto_update_cls = self.available_auto_updates[auto_update]
|
||||
window = auto_update_cls()
|
||||
else:
|
||||
|
||||
auto_update = "auto_updates"
|
||||
window = AutoUpdates()
|
||||
|
||||
window.resize(window.minimumSizeHint())
|
||||
window.setWindowTitle(f"BEC - {window.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(
|
||||
self, widget: type[BECWidget], geometry: tuple[int, int, int, int] | None = None
|
||||
) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
window = BECMainWindowNoRPC()
|
||||
|
||||
widget_instance = widget(root_widget=True, object_name=name)
|
||||
assert isinstance(widget_instance, QWidget)
|
||||
|
||||
window.setCentralWidget(widget_instance)
|
||||
window.resize(window.minimumSizeHint())
|
||||
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
"""
|
||||
for tile in self.tiles.values():
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
|
||||
def _open_auto_update(self):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tiles["auto_update"].selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tiles["auto_update"].selector.currentText()
|
||||
if auto_update == "Default":
|
||||
auto_update = None
|
||||
return self.launch("auto_update", auto_update=auto_update)
|
||||
|
||||
def _open_dock_area(self):
|
||||
"""
|
||||
Open Advanced Dock Area using the selected profile.
|
||||
"""
|
||||
tile = self.tiles.get("dock_area")
|
||||
if tile is None or tile.selector is None:
|
||||
startup_profile = None
|
||||
else:
|
||||
selection = tile.selector.currentText().strip()
|
||||
if selection == START_EMPTY_PROFILE_OPTION:
|
||||
startup_profile = None
|
||||
else:
|
||||
startup_profile = selection if selection else None
|
||||
return self.launch("dock_area", startup_profile=startup_profile)
|
||||
|
||||
def _open_widget(self):
|
||||
"""
|
||||
Open a widget from the available widgets.
|
||||
"""
|
||||
if self.tiles["widget"].selector is None:
|
||||
return
|
||||
widget = self.tiles["widget"].selector.currentText()
|
||||
if widget not in self.available_widgets:
|
||||
raise ValueError(f"Widget {widget} not found in available widgets.")
|
||||
return self.launch("widget", widget=self.available_widgets[widget])
|
||||
|
||||
def _default_launch_geometry(self) -> tuple[int, int, int, int] | None:
|
||||
width, height = self.DEFAULT_LAUNCH_SIZE
|
||||
return centered_geometry_for_app(width=width, height=height)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
"""
|
||||
Open a file dialog to select a custom UI file and launch it.
|
||||
"""
|
||||
ui_file, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
|
||||
)
|
||||
self.launch("custom_ui_file", ui_file=ui_file)
|
||||
|
||||
@staticmethod
|
||||
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Load all available auto updates from the plugin repository.
|
||||
"""
|
||||
try:
|
||||
auto_updates = get_plugin_auto_updates()
|
||||
logger.info(f"Available auto updates: {auto_updates.keys()}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load auto updates: {exc}")
|
||||
return {}
|
||||
return auto_updates
|
||||
|
||||
def show_launcher(self):
|
||||
"""
|
||||
Show the launcher window.
|
||||
"""
|
||||
self.show()
|
||||
|
||||
def hide_launcher(self):
|
||||
"""
|
||||
Hide the launcher window.
|
||||
"""
|
||||
self.hide()
|
||||
|
||||
def showEvent(self, event):
|
||||
self._refresh_dock_area_profiles()
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
def _launcher_is_last_widget(self, connections: dict) -> bool:
|
||||
"""
|
||||
Check if the launcher is the last widget in the application.
|
||||
"""
|
||||
|
||||
# get all parents of connections
|
||||
for connection in connections.values():
|
||||
try:
|
||||
parent = connection.parent()
|
||||
if parent is None and connection.objectName() != self.objectName():
|
||||
logger.info(
|
||||
f"Found non-launcher connection without parent: {connection.objectName()}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting parent of connection: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
if self._launcher_is_last_widget(connections):
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
return
|
||||
|
||||
self.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Close the launcher window.
|
||||
"""
|
||||
connections = self.register.list_all_connections()
|
||||
if self._launcher_is_last_widget(connections):
|
||||
event.accept()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
self.hide()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
launcher = LaunchWindow()
|
||||
launcher.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,397 +0,0 @@
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QAction # type: ignore
|
||||
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.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.guided_tour import GuidedTour
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.utils.screen_utils import (
|
||||
apply_centered_size,
|
||||
available_screen_geometry,
|
||||
main_app_size_for_screen,
|
||||
)
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
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()
|
||||
|
||||
# Initialize guided tour
|
||||
self.guided_tour = GuidedTour(self)
|
||||
self._setup_guided_tour()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.dock_area = DockAreaView(self)
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
# TODO temporary disable until the bugs with BECShell are resolved
|
||||
# self.add_view(
|
||||
# icon="code_blocks",
|
||||
# title="IDE",
|
||||
# widget=self.developer_view,
|
||||
# mini_text="IDE",
|
||||
# exclusive=True,
|
||||
# )
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, view_id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, view_id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# Add guided tour to Help menu
|
||||
self._add_guided_tour_to_menu()
|
||||
|
||||
# --- 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,
|
||||
view_id: str | None = None,
|
||||
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.
|
||||
view_id(str, optional): Unique ID for the view/item. If omitted, uses mini_text;
|
||||
if mini_text is also omitted, uses title.
|
||||
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.
|
||||
|
||||
|
||||
"""
|
||||
resolved_id = sanitize_namespace(view_id or mini_text or title)
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=resolved_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
|
||||
view_widget.view_id = resolved_id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, view_id=resolved_id, title=title)
|
||||
|
||||
view_widget.change_object_name(resolved_id)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[resolved_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()
|
||||
|
||||
def _setup_guided_tour(self):
|
||||
"""
|
||||
Setup the guided tour for the main application.
|
||||
Registers key UI components and delegates to views for their internal components.
|
||||
"""
|
||||
tour_steps = []
|
||||
|
||||
# --- General Layout Components ---
|
||||
|
||||
# Register the sidebar toggle button
|
||||
toggle_step = self.guided_tour.register_widget(
|
||||
widget=self.sidebar.toggle,
|
||||
title="Sidebar Toggle",
|
||||
text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.",
|
||||
)
|
||||
tour_steps.append(toggle_step)
|
||||
|
||||
# Register the sidebar icons
|
||||
sidebar_dock_area = self.sidebar.components.get("dock_area")
|
||||
if sidebar_dock_area:
|
||||
dock_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_dock_area,
|
||||
title="Dock Area View",
|
||||
text="Click here to access the Dock Area view, where you can manage and arrange your dockable panels.",
|
||||
)
|
||||
tour_steps.append(dock_step)
|
||||
|
||||
sidebar_device_manager = self.sidebar.components.get("device_manager")
|
||||
if sidebar_device_manager:
|
||||
device_manager_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_device_manager,
|
||||
title="Device Manager View",
|
||||
text="Click here to open the Device Manager view, where you can view and manage device configs.",
|
||||
)
|
||||
tour_steps.append(device_manager_step)
|
||||
|
||||
sidebar_developer_view = self.sidebar.components.get("developer_view")
|
||||
if sidebar_developer_view:
|
||||
developer_view_step = self.guided_tour.register_widget(
|
||||
widget=sidebar_developer_view,
|
||||
title="Developer View",
|
||||
text="Click here to access the Developer view to write scripts and makros.",
|
||||
)
|
||||
tour_steps.append(developer_view_step)
|
||||
|
||||
# Register the dark mode toggle
|
||||
dark_mode_item = self.sidebar.components.get("dark_mode")
|
||||
if dark_mode_item:
|
||||
dark_mode_step = self.guided_tour.register_widget(
|
||||
widget=dark_mode_item,
|
||||
title="Theme Toggle",
|
||||
text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.",
|
||||
)
|
||||
tour_steps.append(dark_mode_step)
|
||||
|
||||
# Register the client info label
|
||||
if hasattr(self, "_client_info_hover"):
|
||||
client_info_step = self.guided_tour.register_widget(
|
||||
widget=self._client_info_hover,
|
||||
title="Client Status",
|
||||
text="Displays status messages and information from the BEC Server.",
|
||||
)
|
||||
tour_steps.append(client_info_step)
|
||||
|
||||
# Register the scan progress bar if available
|
||||
if hasattr(self, "_scan_progress_hover"):
|
||||
progress_step = self.guided_tour.register_widget(
|
||||
widget=self._scan_progress_hover,
|
||||
title="Scan Progress",
|
||||
text="Monitor the progress of ongoing scans. Hover over the progress bar to see detailed information including elapsed time and estimated completion.",
|
||||
)
|
||||
tour_steps.append(progress_step)
|
||||
|
||||
# Register the notification indicator in the status bar
|
||||
if hasattr(self, "notification_indicator"):
|
||||
notif_step = self.guided_tour.register_widget(
|
||||
widget=self.notification_indicator,
|
||||
title="Notification Center",
|
||||
text="View system notifications, errors, and status updates. Click to filter notifications by type or expand to see all details.",
|
||||
)
|
||||
tour_steps.append(notif_step)
|
||||
|
||||
# --- View-Specific Components ---
|
||||
|
||||
# Register all views that can extend the tour
|
||||
for view_id, view_index in self._view_index.items():
|
||||
view_widget = self.stack.widget(view_index)
|
||||
if not view_widget or not hasattr(view_widget, "register_tour_steps"):
|
||||
continue
|
||||
|
||||
# Get the view's tour steps
|
||||
view_tour = view_widget.register_tour_steps(self.guided_tour, self)
|
||||
if view_tour is None:
|
||||
if hasattr(view_widget.content, "register_tour_steps"):
|
||||
view_tour = view_widget.content.register_tour_steps(self.guided_tour, self)
|
||||
if view_tour is None:
|
||||
continue
|
||||
|
||||
# Get the corresponding sidebar navigation item
|
||||
nav_item = self.sidebar.components.get(view_id)
|
||||
if not nav_item:
|
||||
continue
|
||||
|
||||
# Use the view's title for the navigation button
|
||||
nav_step = self.guided_tour.register_widget(
|
||||
widget=nav_item,
|
||||
title=view_tour.view_title,
|
||||
text=f"Let's explore the features of the {view_tour.view_title}.",
|
||||
)
|
||||
tour_steps.append(nav_step)
|
||||
tour_steps.extend(view_tour.step_ids)
|
||||
|
||||
# Create the tour with all registered steps
|
||||
if tour_steps:
|
||||
self.guided_tour.create_tour(tour_steps)
|
||||
|
||||
def start_guided_tour(self):
|
||||
"""
|
||||
Public method to start the guided tour.
|
||||
This can be called programmatically or connected to a menu/button action.
|
||||
"""
|
||||
self.guided_tour.start_tour()
|
||||
|
||||
def _add_guided_tour_to_menu(self):
|
||||
"""
|
||||
Add a 'Guided Tour' action to the Help menu.
|
||||
"""
|
||||
|
||||
# Find the Help menu
|
||||
menu_bar = self.menuBar()
|
||||
help_menu = None
|
||||
for action in menu_bar.actions():
|
||||
if action.text() == "Help":
|
||||
help_menu = action.menu()
|
||||
break
|
||||
|
||||
if help_menu:
|
||||
# Add separator before the tour action
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Create and add the guided tour action
|
||||
tour_action = QAction("Start Guided Tour", self)
|
||||
tour_action.setIcon(material_icon("help"))
|
||||
tour_action.triggered.connect(self.start_guided_tour)
|
||||
tour_action.setShortcut("F1") # Add keyboard shortcut
|
||||
help_menu.addAction(tour_action)
|
||||
|
||||
def cleanup(self):
|
||||
for view_id, idx in self._view_index.items():
|
||||
view = self.stack.widget(idx)
|
||||
view.close()
|
||||
view.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
"""
|
||||
Main function to run the BEC main application, exposed as a script entry point through
|
||||
pyproject.toml.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
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)
|
||||
|
||||
screen_geometry = available_screen_geometry()
|
||||
if screen_geometry is not None:
|
||||
width, height = main_app_size_for_screen(screen_geometry)
|
||||
apply_centered_size(w, width, height, available=screen_geometry)
|
||||
else:
|
||||
w.resize(w.minimumSizeHint())
|
||||
|
||||
w.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,114 +0,0 @@
|
||||
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
|
||||
]
|
||||
@@ -1,357 +0,0 @@
|
||||
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)
|
||||
@@ -1,370 +0,0 @@
|
||||
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()
|
||||
@@ -1,136 +0,0 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
"""
|
||||
A view for users to write scripts and macros and execute them within the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register Developer View components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
step_ids = []
|
||||
dev_widget = self.developer_widget
|
||||
|
||||
# IDE Toolbar
|
||||
def get_ide_toolbar():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.toolbar, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_toolbar,
|
||||
title="IDE Toolbar",
|
||||
text="Quick access to save files, execute scripts, and configure IDE settings. Use the toolbar to manage your code and execution.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Explorer
|
||||
def get_ide_explorer():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.explorer_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_explorer,
|
||||
title="File Explorer",
|
||||
text="Browse and manage your macro files. Create new files, open existing ones, and organize your scripts.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Editor
|
||||
def get_ide_editor():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.monaco_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_editor,
|
||||
title="Code Editor",
|
||||
text="Write and edit Python code with syntax highlighting, auto-completion, and signature help. Monaco editor provides a modern coding experience.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Console
|
||||
def get_ide_console():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.console_dock.widget(), None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_console,
|
||||
title="BEC Shell Console",
|
||||
text="Interactive Python console with BEC integration. Execute commands, test code snippets, and interact with the BEC system in real-time.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# IDE Plotting Area
|
||||
def get_ide_plotting():
|
||||
main_app.set_current("developer_view")
|
||||
return (dev_widget.plotting_ads, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_ide_plotting,
|
||||
title="Plotting Area",
|
||||
text="View plots and visualizations generated by your scripts. Arrange multiple plots in a flexible layout.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
return ViewTourSteps(view_title="Developer View", step_ids=step_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=developer_view,
|
||||
view_id="developer_view",
|
||||
exclusive=True,
|
||||
)
|
||||
_app.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,457 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import markdown
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import ProcedureRequestMessage
|
||||
from bec_lib.script_executor import upload_script
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QKeySequence, QShortcut # type: ignore
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
|
||||
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.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.control.procedure_control.procedure_panel import ProcedurePanel
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
def markdown_to_html(md_text: str) -> str:
|
||||
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
|
||||
|
||||
# Preprocess: convert consecutive >>> lines to Python code blocks
|
||||
def replace_python_examples(match):
|
||||
indent = match.group(1)
|
||||
examples = match.group(2)
|
||||
# Remove >>> prefix and clean up the code
|
||||
lines = []
|
||||
for line in examples.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(">>> "):
|
||||
lines.append(line[4:]) # Remove '>>> '
|
||||
elif line.startswith(">>>"):
|
||||
lines.append(line[3:]) # Remove '>>>'
|
||||
code = "\n".join(lines)
|
||||
|
||||
return f"{indent}```python\n{indent}{code}\n{indent}```"
|
||||
|
||||
# Match one or more consecutive >>> lines (with same indentation)
|
||||
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
|
||||
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
|
||||
|
||||
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
|
||||
html = markdown.markdown(
|
||||
md_text,
|
||||
extensions=extensions,
|
||||
extension_configs={
|
||||
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
|
||||
},
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
# Remove hardcoded background colors that conflict with themes
|
||||
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
|
||||
html = re.sub(r"background: #[^;]*;", "", html)
|
||||
|
||||
# Add CSS to force code blocks to wrap
|
||||
css = """
|
||||
<style>
|
||||
pre, code {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
.codehilite pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
return css + html
|
||||
|
||||
|
||||
class DeveloperWidget(DockAreaWidget):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", **kwargs)
|
||||
|
||||
# Promote toolbar above the dock manager provided by the base class
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_developer_toolbar()
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.explorer.setObjectName("Explorer")
|
||||
|
||||
self.console = BECShell(self, rpc_exposed=False)
|
||||
self.console.setObjectName("BEC Shell")
|
||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = BECDockArea(
|
||||
self,
|
||||
mode="plot",
|
||||
default_add_direction="bottom",
|
||||
profile_namespace="developer_plotting",
|
||||
auto_profile_namespace=False,
|
||||
enable_profile_management=False,
|
||||
variant="compact",
|
||||
)
|
||||
self.plotting_ads.setObjectName("PlottingArea")
|
||||
self.signature_help = QTextEdit(self)
|
||||
self.signature_help.setObjectName("Signature Help")
|
||||
self.signature_help.setAcceptRichText(True)
|
||||
self.signature_help.setReadOnly(True)
|
||||
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
opt = self.signature_help.document().defaultTextOption()
|
||||
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
|
||||
self.signature_help.document().setDefaultTextOption(opt)
|
||||
self.monaco.signature_help.connect(
|
||||
lambda text: self.signature_help.setHtml(markdown_to_html(text))
|
||||
)
|
||||
self._current_script_id: str | None = None
|
||||
self.script_editor_tab = None
|
||||
|
||||
self.procedures = ProcedurePanel(self)
|
||||
self.procedures.setObjectName("Procedure Control")
|
||||
|
||||
self._initialize_layout()
|
||||
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
|
||||
self.monaco.focused_editor.connect(self._on_focused_editor_changed)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
def _initialize_layout(self) -> None:
|
||||
"""Create the default dock arrangement for the developer workspace."""
|
||||
|
||||
# Monaco editor as the central dock
|
||||
self.monaco_dock = self.new(
|
||||
self.monaco,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
show_title_bar=False,
|
||||
show_settings_action=False,
|
||||
title_buttons={"float": False, "close": False, "menu": False},
|
||||
# promote_central=True,
|
||||
)
|
||||
|
||||
# Explorer on the left without a title bar
|
||||
self.explorer_dock = self.new(
|
||||
self.explorer,
|
||||
where="left",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Console and terminal tabbed along the bottom
|
||||
self.console_dock = self.new(
|
||||
self.console,
|
||||
relative_to=self.monaco_dock,
|
||||
where="bottom",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
title_buttons={"float": True, "close": False},
|
||||
)
|
||||
self.terminal_dock = self.new(
|
||||
self.terminal,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
tab_with=self.console_dock,
|
||||
return_dock=True,
|
||||
title_buttons={"float": False, "close": False},
|
||||
)
|
||||
|
||||
# Plotting area on the right with signature help tabbed alongside
|
||||
_r_panel = {
|
||||
"closable": False,
|
||||
"floatable": False,
|
||||
"movable": False,
|
||||
"return_dock": True,
|
||||
"title_buttons": {"float": True},
|
||||
}
|
||||
self.plotting_dock = self.new(self.plotting_ads, where="right", **_r_panel)
|
||||
self.signature_dock = self.new(self.signature_help, **_r_panel, tab_with=self.plotting_dock)
|
||||
self.procedure_dock = self.new(self.procedures, **_r_panel, tab_with=self.plotting_dock)
|
||||
|
||||
self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3])
|
||||
|
||||
def init_developer_toolbar(self):
|
||||
"""Initialize the developer toolbar with necessary actions and widgets."""
|
||||
save_button = MaterialIconAction(
|
||||
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
|
||||
)
|
||||
save_button.action.triggered.connect(self.on_save)
|
||||
self.toolbar.components.add_safe("save", save_button)
|
||||
|
||||
save_as_button = MaterialIconAction(
|
||||
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("save_as", save_as_button)
|
||||
save_as_button.action.triggered.connect(self.on_save_as)
|
||||
|
||||
save_bundle = ToolbarBundle("save", self.toolbar.components)
|
||||
save_bundle.add_action("save")
|
||||
save_bundle.add_action("save_as")
|
||||
self.toolbar.add_bundle(save_bundle)
|
||||
|
||||
run_action = MaterialIconAction(
|
||||
icon_name="play_arrow",
|
||||
tooltip="Run current file",
|
||||
label_text="Run",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
submit_action = MaterialIconAction(
|
||||
icon_name="animated_images",
|
||||
tooltip="Run current file as a BEC procedure",
|
||||
label_text="Run on server",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
submit_action.action.triggered.connect(self.on_submit_procedure)
|
||||
self.toolbar.components.add_safe("run_proc", submit_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop",
|
||||
tooltip="Stop current execution",
|
||||
label_text="Stop",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
stop_action.action.triggered.connect(self.on_stop)
|
||||
self.toolbar.components.add_safe("stop", stop_action)
|
||||
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
execution_bundle.add_action("run_proc")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
icon_name="vim",
|
||||
tooltip="Toggle Vim Mode",
|
||||
label_text="Vim",
|
||||
filled=True,
|
||||
parent=self,
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.components.add_safe("vim", vim_action)
|
||||
vim_action.action.triggered.connect(self.on_vim_triggered)
|
||||
|
||||
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
|
||||
settings_bundle.add_action("vim")
|
||||
self.toolbar.add_bundle(settings_bundle)
|
||||
|
||||
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
save_shortcut.activated.connect(self.on_save)
|
||||
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
|
||||
save_as_shortcut.activated.connect(self.on_save_as)
|
||||
|
||||
def _open_new_file(self, file_name: str, scope: str):
|
||||
self.monaco.open_file(file_name, scope)
|
||||
|
||||
# Set read-only mode for shared files
|
||||
if "shared" in scope:
|
||||
self.monaco.set_file_readonly(file_name, True)
|
||||
|
||||
# Add appropriate icon based on file type
|
||||
if "script" in scope:
|
||||
# Use script icon for script files
|
||||
icon = material_icon("script", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
elif "macro" in scope:
|
||||
# Use function icon for macro files
|
||||
icon = material_icon("function", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
"""Save the currently focused file in the Monaco editor."""
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
"""Save the currently focused file in the Monaco editor with a 'Save As' dialog."""
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
"""Toggle Vim mode in the Monaco editor."""
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_save_enabled_update(self, enabled: bool):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
def _try_upload(self) -> str | None:
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return None
|
||||
if not isinstance(widget := self.script_editor_tab.widget(), MonacoWidget):
|
||||
return None
|
||||
if widget.modified:
|
||||
# Save the file before execution if there are unsaved changes
|
||||
self.monaco.save_file()
|
||||
if widget.modified:
|
||||
# If still modified, user likely cancelled save dialog
|
||||
return None
|
||||
return upload_script(self.client.connector, widget.get_text())
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor."""
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
if (script_id := self._try_upload()) is not None:
|
||||
self.current_script_id = script_id
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_submit_procedure(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor as a procedure."""
|
||||
if (script_id := self._try_upload()) is not None:
|
||||
self.current_script_id = script_id
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.procedure_request(),
|
||||
ProcedureRequestMessage(
|
||||
identifier="run_script", args_kwargs=((self.current_script_id,), {})
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
"""Stop the execution of the currently running script"""
|
||||
if not self.current_script_id:
|
||||
return
|
||||
self.console.send_ctrl_c()
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
"""Get the ID of the currently running script."""
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value: str | None):
|
||||
"""
|
||||
Set the ID of the currently running script.
|
||||
|
||||
Args:
|
||||
value (str | None): The script ID to set.
|
||||
Raises:
|
||||
ValueError: If the provided value is not a string or None.
|
||||
"""
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
old_script_id = self._current_script_id
|
||||
self._current_script_id = value
|
||||
self._update_subscription(value, old_script_id)
|
||||
|
||||
def _update_subscription(self, new_script_id: str | None, old_script_id: str | None):
|
||||
if old_script_id is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id)
|
||||
)
|
||||
if new_script_id is not None:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
|
||||
)
|
||||
|
||||
@SafeSlot(CDockWidget)
|
||||
def _on_focused_editor_changed(self, tab_widget: CDockWidget):
|
||||
"""
|
||||
Disable the run / stop buttons if the focused editor is a macro file.
|
||||
Args:
|
||||
tab_widget: The currently focused tab widget in the Monaco editor.
|
||||
"""
|
||||
if not isinstance(tab_widget, CDockWidget):
|
||||
return
|
||||
widget = tab_widget.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
file_scope = widget.metadata.get("scope", "")
|
||||
run_action = self.toolbar.components.get_action("run")
|
||||
stop_action = self.toolbar.components.get_action("stop")
|
||||
if "macro" in file_scope:
|
||||
run_action.action.setEnabled(False)
|
||||
stop_action.action.setEnabled(False)
|
||||
else:
|
||||
run_action.action.setEnabled(True)
|
||||
stop_action.action.setEnabled(True)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
"""
|
||||
Handle script execution info messages to update the editor highlights.
|
||||
Args:
|
||||
content (dict): The content of the message containing execution info.
|
||||
metadata (dict): Additional metadata for the message.
|
||||
"""
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if self.script_editor_tab is None:
|
||||
return
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
if not current_lines:
|
||||
widget.clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
widget.clear_highlighted_lines()
|
||||
widget.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources used by the developer widget."""
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,2 +0,0 @@
|
||||
from .config_choice_dialog import ConfigChoiceDialog
|
||||
from .device_form_dialog import DeviceFormDialog
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Dialog to choose config loading method: replace, add or cancel."""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
class Result(IntEnum):
|
||||
CANCEL = QDialog.Rejected
|
||||
ADD = 2
|
||||
REPLACE = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
custom_label: str = "Do you want to replace the current config or add to it?",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel(custom_label)
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Use QDialogButtonBox for native layout
|
||||
self.button_box = QDialogButtonBox(self)
|
||||
self.cancel_btn = self.button_box.addButton(
|
||||
"Cancel", QDialogButtonBox.ButtonRole.ActionRole # RejectRole will be next to Accept...
|
||||
)
|
||||
self.replace_btn = self.button_box.addButton(
|
||||
"Replace", QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
self.add_btn = self.button_box.addButton("Add", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
|
||||
layout.addWidget(self.button_box)
|
||||
|
||||
for btn in [self.replace_btn, self.add_btn, self.cancel_btn]:
|
||||
btn.setMinimumWidth(80)
|
||||
btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
# Connections using native done(int)
|
||||
self.replace_btn.clicked.connect(lambda: self.done(self.Result.REPLACE))
|
||||
self.add_btn.clicked.connect(lambda: self.done(self.Result.ADD))
|
||||
self.cancel_btn.clicked.connect(lambda: self.done(self.Result.CANCEL))
|
||||
|
||||
self.replace_btn.setFocus()
|
||||
@@ -1,447 +0,0 @@
|
||||
"""Dialogs for device configuration forms and ophyd testing."""
|
||||
|
||||
from typing import Any, Iterable, Tuple
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components import OphydValidation
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import (
|
||||
DeviceConfigTemplate,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import (
|
||||
validate_name,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
format_error_to_md,
|
||||
)
|
||||
|
||||
DEFAULT_DEVICE = "CustomDevice"
|
||||
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
"""Popup dialog to test Ophyd device configurations interactively."""
|
||||
|
||||
def __init__(self, parent=None, config: dict | None = None): # type: ignore
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Device Manager Ophyd Test")
|
||||
self._config_status = ConfigStatus.UNKNOWN.value
|
||||
self._connection_status = ConnectionStatus.UNKNOWN.value
|
||||
self._validated_config: dict = {}
|
||||
self._validation_msg: str = ""
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Core test widget
|
||||
self.device_manager_ophyd_test = OphydValidation()
|
||||
layout.addWidget(self.device_manager_ophyd_test)
|
||||
|
||||
# Log/Markdown box for messages
|
||||
self.text_box = QtWidgets.QTextEdit()
|
||||
self.text_box.setReadOnly(True)
|
||||
layout.addWidget(self.text_box)
|
||||
|
||||
# Load and apply configuration
|
||||
config = config or {}
|
||||
device_name = config.get("name", None)
|
||||
if device_name:
|
||||
self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name)
|
||||
|
||||
# Dialog Buttons: equal size, stacked horizontally
|
||||
button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close)
|
||||
for button in button_box.buttons():
|
||||
button.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed
|
||||
)
|
||||
button.clicked.connect(self.accept)
|
||||
# button_box.setCenterButtons(False)
|
||||
layout.addWidget(button_box)
|
||||
self.device_manager_ophyd_test.validation_completed.connect(self._on_device_validated)
|
||||
self._resize_dialog()
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
# Add and test device config
|
||||
self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True)
|
||||
|
||||
def _resize_dialog(self):
|
||||
"""Resize the dialog based on the screen size."""
|
||||
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 4:3 ratio
|
||||
height = int(screen_height * 0.7)
|
||||
width = int(height * (4 / 3))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (4 / 3))
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
def _on_device_validated(
|
||||
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
|
||||
):
|
||||
device_name = device_config.get("name", "")
|
||||
self._config_status = config_status
|
||||
self._connection_status = connection_status
|
||||
self._validated_config = device_config
|
||||
self._validation_msg = validation_msg
|
||||
self.text_box.setMarkdown(format_error_to_md(device_name, validation_msg))
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
self.device_manager_ophyd_test.close()
|
||||
self.device_manager_ophyd_test.deleteLater()
|
||||
|
||||
@property
|
||||
def validation_result(self) -> tuple[dict, int, int, str]:
|
||||
"""
|
||||
Return the result of the validation as a tuple of
|
||||
|
||||
Returns:
|
||||
result (Tuple[dict, int, int]): A tuple containing:
|
||||
validated_config (dict): The validated device configuration.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
|
||||
"""
|
||||
return (
|
||||
self._validated_config,
|
||||
self._config_status,
|
||||
self._connection_status,
|
||||
self._validation_msg,
|
||||
)
|
||||
|
||||
|
||||
class DeviceFormDialog(QtWidgets.QDialog):
|
||||
|
||||
# Signal emitted when device configuration is accepted, only
|
||||
# emitted when the user clicks the "Add Device" button
|
||||
# The integer values indicate if the device config was
|
||||
# validated: config_status, connection_status
|
||||
accepted_data = QtCore.Signal(dict, int, int, str, str)
|
||||
|
||||
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type: ignore
|
||||
super().__init__(parent)
|
||||
# Track old device name if config is edited
|
||||
self._old_device_name: str = ""
|
||||
|
||||
# Config validation result
|
||||
self._validation_result: tuple[dict, int, int, str] = (
|
||||
{},
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
"",
|
||||
)
|
||||
# Group to variants mapping
|
||||
self._group_variants: dict[str, list[str]] = {
|
||||
group: [variant for variant in variants.keys()]
|
||||
for group, variants in OPHYD_DEVICE_TEMPLATES.items()
|
||||
}
|
||||
|
||||
self._control_widgets: dict[str, QtWidgets.QWidget] = {}
|
||||
|
||||
# Setup layout
|
||||
self.setWindowTitle("Device Config Dialog")
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Control panel
|
||||
self._control_box = self.create_control_panel()
|
||||
layout.addWidget(self._control_box)
|
||||
|
||||
# Device config template display
|
||||
self._device_config_template = DeviceConfigTemplate(parent=self)
|
||||
self._frame = QtWidgets.QFrame()
|
||||
self._frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
self._frame.setFrameShadow(QtWidgets.QFrame.Raised)
|
||||
frame_layout = QtWidgets.QVBoxLayout(self._frame)
|
||||
frame_layout.addWidget(self._device_config_template)
|
||||
layout.addWidget(self._frame)
|
||||
|
||||
# Custom buttons
|
||||
self.add_btn = QtWidgets.QPushButton(add_btn_text)
|
||||
self.test_connection_btn = QtWidgets.QPushButton("Test Connection")
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.reset_btn = QtWidgets.QPushButton("Reset Form")
|
||||
|
||||
btn_box = QtWidgets.QDialogButtonBox(self)
|
||||
btn_box.addButton(self.cancel_btn, QtWidgets.QDialogButtonBox.ButtonRole.RejectRole)
|
||||
btn_box.addButton(self.reset_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
|
||||
btn_box.addButton(
|
||||
self.test_connection_btn, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole
|
||||
)
|
||||
btn_box.addButton(self.add_btn, QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
for btn in btn_box.buttons():
|
||||
btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
|
||||
layout.addWidget(btn_box)
|
||||
|
||||
frame_layout.addWidget(btn_box)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.add_btn.clicked.connect(self._add_config)
|
||||
self.test_connection_btn.clicked.connect(self._test_connection)
|
||||
self.reset_btn.clicked.connect(self._reset_config)
|
||||
self.cancel_btn.clicked.connect(self._reject_config)
|
||||
|
||||
# layout.addWidget(self._device_config_template)
|
||||
self.update_variant_combo(self._control_widgets["group_combo"].currentText())
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
# Wait dialog when adding config
|
||||
self._wait_dialog: QtWidgets.QProgressDialog | None = None
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
for widget in self._control_widgets.values():
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
if self._wait_dialog is not None:
|
||||
self._wait_dialog.close()
|
||||
self._wait_dialog.deleteLater()
|
||||
|
||||
@property
|
||||
def config_validation_result(self) -> tuple[dict, int, int, str]:
|
||||
"""Return the result of the last configuration validation."""
|
||||
return self._validation_result
|
||||
|
||||
@config_validation_result.setter
|
||||
def config_validation_result(self, result: tuple[dict, int, int, str]):
|
||||
self._validation_result = result
|
||||
|
||||
def set_device_config(self, device_config: dict):
|
||||
"""Set the device configuration in the template form."""
|
||||
# Figure out which group and variant this config belongs to
|
||||
device_class = device_config.get("deviceClass", None)
|
||||
for group, variants in OPHYD_DEVICE_TEMPLATES.items():
|
||||
for variant, template_info in variants.items():
|
||||
if template_info.get("deviceClass", None) == device_class:
|
||||
# Found the matching group and variant
|
||||
self._control_widgets["group_combo"].setCurrentText(group)
|
||||
self.update_variant_combo(group)
|
||||
self._control_widgets["variant_combo"].setCurrentText(variant)
|
||||
self._device_config_template.set_config_fields(device_config)
|
||||
return
|
||||
# If no match found, set to default
|
||||
self._control_widgets["group_combo"].setCurrentText(DEFAULT_DEVICE)
|
||||
self.update_variant_combo(DEFAULT_DEVICE)
|
||||
self._device_config_template.set_config_fields(device_config)
|
||||
self._old_device_name = device_config.get("name", "")
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
return QtCore.QSize(1600, 1000)
|
||||
|
||||
def create_control_panel(self) -> QtWidgets.QGroupBox:
|
||||
self._control_box = QtWidgets.QGroupBox("Choose a Device Group")
|
||||
layout = QtWidgets.QGridLayout(self._control_box)
|
||||
|
||||
group_label = QtWidgets.QLabel("Device Group:")
|
||||
layout.addWidget(group_label, 0, 0)
|
||||
|
||||
group_combo = QtWidgets.QComboBox()
|
||||
group_combo.addItems(self._group_variants.keys())
|
||||
self._control_widgets["group_combo"] = group_combo
|
||||
layout.addWidget(group_combo, 1, 0)
|
||||
|
||||
variant_label = QtWidgets.QLabel("Variants:")
|
||||
layout.addWidget(variant_label, 0, 1)
|
||||
|
||||
variant_combo = QtWidgets.QComboBox()
|
||||
self._control_widgets["variant_combo"] = variant_combo
|
||||
layout.addWidget(variant_combo, 1, 1)
|
||||
|
||||
group_combo.currentTextChanged.connect(self.update_variant_combo)
|
||||
variant_combo.currentTextChanged.connect(self.update_device_config_template)
|
||||
|
||||
return self._control_box
|
||||
|
||||
def update_variant_combo(self, group_name: str):
|
||||
variant_combo = self._control_widgets["variant_combo"]
|
||||
variant_combo.clear()
|
||||
variant_combo.addItems(self._group_variants.get(group_name, []))
|
||||
if variant_combo.count() <= 1:
|
||||
variant_combo.setEnabled(False)
|
||||
else:
|
||||
variant_combo.setEnabled(True)
|
||||
|
||||
def update_device_config_template(self, variant_name: str):
|
||||
group_name = self._control_widgets["group_combo"].currentText()
|
||||
template_info = OPHYD_DEVICE_TEMPLATES.get(group_name, {}).get(variant_name, {})
|
||||
if template_info:
|
||||
self._device_config_template.change_template(template_info)
|
||||
else:
|
||||
self._device_config_template.change_template(
|
||||
OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE]
|
||||
)
|
||||
|
||||
def _create_validation_dialog(self) -> QtWidgets.QProgressDialog:
|
||||
"""
|
||||
Create and show a validation progress dialog while validating the device configuration.
|
||||
The dialog will be modal and prevent user interaction until validation is complete.
|
||||
"""
|
||||
wait_dialog = QtWidgets.QProgressDialog(
|
||||
"Validating config... please wait", None, 0, 0, parent=self
|
||||
)
|
||||
wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
|
||||
wait_dialog.setCancelButton(None)
|
||||
wait_dialog.setMinimumDuration(0)
|
||||
return wait_dialog
|
||||
|
||||
def _create_and_run_ophyd_validation(self, config: dict[str, Any]) -> OphydValidation:
|
||||
"""Run ophyd validation test on the current device configuration."""
|
||||
ophyd_validation = OphydValidation(parent=self)
|
||||
ophyd_validation.validation_completed.connect(self._handle_validation_result)
|
||||
ophyd_validation.multiple_validations_completed.connect(
|
||||
self._handle_devices_already_in_session_results
|
||||
)
|
||||
|
||||
# NOTE Use singleShot here to ensure that the signal is emitted after all other scheduled
|
||||
# tasks in the event loop are processed. This avoids potential deadlocks. In particular,
|
||||
# this is relevant for the _wait_dialog exec which opens a modal dialog during validation
|
||||
# and therefore must not have the signal emitted immediately in the same event loop iteration.
|
||||
# Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock.
|
||||
QtCore.QTimer.singleShot(
|
||||
0, lambda: ophyd_validation.change_device_configs([config], True, False)
|
||||
)
|
||||
return ophyd_validation
|
||||
|
||||
@SafeSlot(list)
|
||||
def _handle_devices_already_in_session_results(
|
||||
self, validation_results: _ValidationResultIter
|
||||
) -> None:
|
||||
"""Handle completion if device is already in session."""
|
||||
if len(validation_results) != 1:
|
||||
logger.error(
|
||||
"Expected a single device validation result, but got multiple. Using first result."
|
||||
)
|
||||
result = validation_results[0] if len(validation_results) > 0 else None
|
||||
if result is None:
|
||||
logger.error(
|
||||
f"Received validation results: {validation_results} of unexpected length 0. Returning."
|
||||
)
|
||||
return
|
||||
device_config, config_status, connection_status, validation_msg = result
|
||||
self._handle_validation_result(
|
||||
device_config, config_status, connection_status, validation_msg
|
||||
)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def _handle_validation_result(
|
||||
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
|
||||
):
|
||||
"""Handle completion of validation."""
|
||||
try:
|
||||
if (
|
||||
DeviceModel.model_validate(device_config)
|
||||
== DeviceModel.model_validate(self._validation_result[0])
|
||||
and connection_status == ConnectionStatus.UNKNOWN.value
|
||||
):
|
||||
# Config unchanged, we can reuse previous connection status. Only do this if the new
|
||||
# connection status is UNKNOWN as the current validation should not test the connection.
|
||||
connection_status = self._validation_result[2]
|
||||
validation_msg = self._validation_result[3]
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"Device config validation changed for config: {device_config} compared to previous validation. Using status from recent validation."
|
||||
)
|
||||
self._validation_result = (device_config, config_status, connection_status, validation_msg)
|
||||
if self._wait_dialog is not None:
|
||||
self._wait_dialog.accept()
|
||||
self._wait_dialog.close()
|
||||
self._wait_dialog.deleteLater()
|
||||
self._wait_dialog = None
|
||||
|
||||
def _add_config(self):
|
||||
"""
|
||||
Adding a config will always run a validation check of the config without a connection test.
|
||||
We will check if tests have already run, and reuse the information in case they also tested the connection to the device.
|
||||
"""
|
||||
config = self._device_config_template.get_config_fields()
|
||||
|
||||
# I. First we validate that the device name is valid, as this may create issues within the OphydValidation widget.
|
||||
# Validate device name first. If invalid, this should immediately block adding the device.
|
||||
if not validate_name(config.get("name", "")):
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Name",
|
||||
f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
# II. Next we will run the validation check of the config without connection test.
|
||||
# We will show a wait dialog while this is happening, and compare the results with the last known validation results.
|
||||
# If the config is unchanged, we will use the connection status results from the last validation.
|
||||
self._wait_dialog = self._create_validation_dialog()
|
||||
ophyd_validation: OphydValidation | None = None
|
||||
try:
|
||||
ophyd_validation = self._create_and_run_ophyd_validation(config)
|
||||
|
||||
# NOTE If dialog was already closed, this means that a validation callback was already received
|
||||
# which closed the dialog. In this case, we skip exec to avoid deadlock. With the singleShot above,
|
||||
# this should not happen, but we keep the check for safety.
|
||||
if self._wait_dialog is not None:
|
||||
self._wait_dialog.exec() # This will block until the validation is complete
|
||||
|
||||
config, config_status, connection_status, validation_msg = self._validation_result
|
||||
|
||||
if config_status == ConfigStatus.INVALID.value:
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Configuration",
|
||||
f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{self._validation_result[3]}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
self.accepted_data.emit(
|
||||
config, config_status, connection_status, validation_msg, self._old_device_name
|
||||
)
|
||||
self.accept()
|
||||
finally:
|
||||
if ophyd_validation is not None:
|
||||
ophyd_validation.close()
|
||||
ophyd_validation.deleteLater()
|
||||
|
||||
def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox:
|
||||
msg_box = QtWidgets.QMessageBox(self)
|
||||
msg_box.setIcon(QtWidgets.QMessageBox.Warning)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
return msg_box
|
||||
|
||||
def _test_connection(self):
|
||||
config = self._device_config_template.get_config_fields()
|
||||
dialog = DeviceManagerOphydValidationDialog(self, config=config)
|
||||
result = dialog.exec()
|
||||
if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected):
|
||||
self.config_validation_result = dialog.validation_result
|
||||
|
||||
def _reset_config(self):
|
||||
self._device_config_template.reset_to_defaults()
|
||||
|
||||
def _reject_config(self):
|
||||
self.reject()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
dialog = DeviceFormDialog()
|
||||
dialog.resize(1200, 800)
|
||||
dialog.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,691 +0,0 @@
|
||||
"""Module for the upload redis dialog in the device manager view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, List, Tuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
get_validation_icons,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.colors import AccentColor
|
||||
from bec_widgets.widgets.control.device_manager.components.device_table.device_table import (
|
||||
_ValidationResultIter,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceStatusItem(QtWidgets.QWidget):
|
||||
"""Individual device status item widget for the validation display."""
|
||||
|
||||
def __init__(
|
||||
self, device_config: dict, config_status: int, connection_status: int, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.device_name = device_config.get("name", "")
|
||||
self.device_config: dict = device_config
|
||||
self.config_status = ConfigStatus(config_status)
|
||||
self.connection_status = ConnectionStatus(connection_status)
|
||||
self._transparent_button_style = "background-color: transparent; border: none;"
|
||||
|
||||
# Get validation icons
|
||||
self.colors = get_accent_colors()
|
||||
self._icon_size = (20, 20)
|
||||
self.icons = get_validation_icons(self.colors, self._icon_size)
|
||||
|
||||
self._setup_ui()
|
||||
self._update_display()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the device status item."""
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 4, 8, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Device name label
|
||||
self.name_label = QtWidgets.QLabel(self.device_name)
|
||||
self.name_label.setMinimumWidth(150)
|
||||
layout.addWidget(self.name_label)
|
||||
layout.addStretch()
|
||||
|
||||
# Config status icon
|
||||
self.config_icon_label = self._create_status_icon_label(self._icon_size)
|
||||
layout.addWidget(self.config_icon_label)
|
||||
|
||||
# Connection status icon
|
||||
self.connection_icon_label = self._create_status_icon_label(self._icon_size)
|
||||
layout.addWidget(self.connection_icon_label)
|
||||
|
||||
def _create_status_icon_label(self, icon_size: tuple[int, int]) -> QtWidgets.QPushButton:
|
||||
button = QtWidgets.QPushButton()
|
||||
button.setFlat(True)
|
||||
button.setEnabled(False)
|
||||
button.setStyleSheet(self._transparent_button_style)
|
||||
button.setFixedSize(icon_size[0], icon_size[1])
|
||||
return button
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the visual display based on current status."""
|
||||
# Update config status
|
||||
config_icon = self.icons["config_status"].get(self.config_status.value)
|
||||
if config_icon:
|
||||
self.config_icon_label.setIcon(config_icon)
|
||||
|
||||
# Update connection status
|
||||
connection_icon = self.icons["connection_status"].get(self.connection_status.value)
|
||||
if connection_icon:
|
||||
self.connection_icon_label.setIcon(connection_icon)
|
||||
|
||||
def update_status(self, config_status: int, connection_status: int):
|
||||
"""Update the status and refresh display."""
|
||||
self.config_status = ConfigStatus(config_status)
|
||||
self.connection_status = ConnectionStatus(connection_status)
|
||||
self._update_display()
|
||||
|
||||
|
||||
class SortTableItem(QtWidgets.QTableWidgetItem):
|
||||
"""Custom TableWidgetItem with hidden __column_data attribute for sorting."""
|
||||
|
||||
def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
"""Override less-than operator for sorting."""
|
||||
if not isinstance(other, QtWidgets.QTableWidgetItem):
|
||||
return NotImplemented
|
||||
self_data = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
other_data = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if self_data is not None and other_data is not None:
|
||||
self_data: DeviceStatusItem
|
||||
other_data: DeviceStatusItem
|
||||
if self_data.config_status != other_data.config_status:
|
||||
return self_data.config_status < other_data.config_status
|
||||
else:
|
||||
return self_data.connection_status < other_data.connection_status
|
||||
return super().__lt__(other)
|
||||
|
||||
def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
"""Override less-than operator for sorting."""
|
||||
if not isinstance(other, QtWidgets.QTableWidgetItem):
|
||||
return NotImplemented
|
||||
self_data = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
other_data = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if self_data is not None and other_data is not None:
|
||||
self_data: DeviceStatusItem
|
||||
other_data: DeviceStatusItem
|
||||
if self_data.config_status != other_data.config_status:
|
||||
return self_data.config_status > other_data.config_status
|
||||
else:
|
||||
return self_data.connection_status > other_data.connection_status
|
||||
return super().__gt__(other)
|
||||
|
||||
|
||||
class ValidationSection(QtWidgets.QGroupBox):
|
||||
"""Section widget for displaying validation results."""
|
||||
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(title, parent=parent)
|
||||
self._setup_ui()
|
||||
# self.device_items: Dict[str, DeviceStatusItem] = {}
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the validation section."""
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Status summary label
|
||||
summary_layout = QtWidgets.QHBoxLayout()
|
||||
self.summary_icon = QtWidgets.QLabel()
|
||||
self.summary_icon.setFixedSize(24, 24)
|
||||
self.summary_label = QtWidgets.QLabel()
|
||||
self.summary_label.setWordWrap(True)
|
||||
summary_layout.addWidget(self.summary_icon)
|
||||
summary_layout.addWidget(self.summary_label)
|
||||
layout.addLayout(summary_layout)
|
||||
|
||||
# Scroll area for device items
|
||||
self.table = QtWidgets.QTableWidget()
|
||||
self.table.setColumnCount(1)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().hide()
|
||||
self.table.verticalHeader().hide()
|
||||
self.table.setShowGrid(False) # r
|
||||
self.table.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
layout.addWidget(self.table)
|
||||
QtCore.QTimer.singleShot(0, self.adjustSize)
|
||||
|
||||
def add_device(self, device_config: dict, config_status: int, connection_status: int):
|
||||
"""
|
||||
Add a device to the validation section.
|
||||
|
||||
Args:
|
||||
device_config (dict): The device configuration dictionary.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
"""
|
||||
self.table.setSortingEnabled(False)
|
||||
device_name = device_config.get("name", "")
|
||||
row = self._find_row_by_name(device_name)
|
||||
if row is not None:
|
||||
widget: DeviceStatusItem = self.table.cellWidget(row, 0)
|
||||
widget.update_status(config_status, connection_status)
|
||||
else:
|
||||
row_position = self.table.rowCount()
|
||||
self.table.insertRow(row_position)
|
||||
sort_item = SortTableItem(device_name)
|
||||
sort_item.setText("")
|
||||
self.table.setItem(row_position, 0, sort_item)
|
||||
device_item = DeviceStatusItem(device_config, config_status, connection_status)
|
||||
sort_item.setData(QtCore.Qt.ItemDataRole.UserRole, device_item)
|
||||
self.table.setCellWidget(row_position, 0, device_item)
|
||||
self.table.resizeRowsToContents()
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def _find_row_by_name(self, device_name: str) -> int | None:
|
||||
"""
|
||||
Find a row by device name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the device to find.
|
||||
Returns:
|
||||
int | None: The row index if found, else None.
|
||||
"""
|
||||
for row in range(self.table.rowCount()):
|
||||
item: SortTableItem = self.table.item(row, 0)
|
||||
widget: DeviceStatusItem = self.table.cellWidget(row, 0)
|
||||
if widget.device_name == device_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
def remove_device(self, device_name: str):
|
||||
"""Remove a device from the table by name."""
|
||||
self.table.setSortingEnabled(False)
|
||||
row = self._find_row_by_name(device_name)
|
||||
if row is not None:
|
||||
self.table.removeRow(row)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def clear_devices(self):
|
||||
"""Clear all device items."""
|
||||
self.table.setSortingEnabled(False)
|
||||
while self.table.rowCount() > 0:
|
||||
self.table.removeRow(0)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def update_summary(self, text: str, icon: QtGui.QPixmap = None):
|
||||
"""Update the summary label."""
|
||||
self.summary_label.setText(text)
|
||||
if icon:
|
||||
self.summary_icon.setPixmap(icon)
|
||||
|
||||
|
||||
class UploadRedisDialog(QtWidgets.QDialog):
|
||||
"""
|
||||
Dialog for uploading device configurations to BEC server with validation checks.
|
||||
"""
|
||||
|
||||
class UploadAction(IntEnum):
|
||||
"""Enum for upload actions."""
|
||||
|
||||
CANCEL = QtWidgets.QDialog.DialogCode.Rejected
|
||||
OK = QtWidgets.QDialog.DialogCode.Accepted
|
||||
CONNECTION_TEST_REQUESTED = 999
|
||||
|
||||
# Request ophyd validation for all untested device connections
|
||||
# list of device configs, added: bool, connect: bool
|
||||
request_ophyd_validation = QtCore.Signal(list, bool, bool)
|
||||
|
||||
def __init__(self, parent, device_configs: dict[str, Tuple[dict, int, int]] | None = None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {}
|
||||
self._transparent_button_style = "background-color: transparent; border: none;"
|
||||
|
||||
self.colors = get_accent_colors()
|
||||
self.icons = get_validation_icons(self.colors, (20, 20))
|
||||
material_icon_partial = partial(material_icon, size=(24, 24), filled=True)
|
||||
self._label_icons = {
|
||||
"success": material_icon_partial("check_circle", color=self.colors.success),
|
||||
"warning": material_icon_partial("warning", color=self.colors.warning),
|
||||
"error": material_icon_partial("error", color=self.colors.emergency),
|
||||
"reload": material_icon_partial("refresh", color=self.colors.default),
|
||||
"upload": material_icon_partial("cloud_upload", color=self.colors.default),
|
||||
}
|
||||
|
||||
# Track validation states
|
||||
self.has_invalid_configs: int = 0
|
||||
self.has_untested_connections: int = 0
|
||||
self.has_cannot_connect: int = 0
|
||||
|
||||
self._setup_ui()
|
||||
self._update_ui()
|
||||
|
||||
def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]):
|
||||
"""
|
||||
Update the device configuration in the dialog.
|
||||
|
||||
Args:
|
||||
device_configs (dict[str, Tuple[dict, int, int]]): New device configurations with structure
|
||||
{device_name: (config_dict, config_status, connection_status)}.
|
||||
"""
|
||||
self.config_section.clear_devices()
|
||||
self.device_configs = device_configs
|
||||
self._update_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the main UI for the dialog."""
|
||||
self.setWindowTitle("Upload Configuration to BEC Server")
|
||||
self.setModal(True) # Blocks interaction with other parts of the app
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header_label = QtWidgets.QLabel("Review Configuration Before Upload")
|
||||
header_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 8px;")
|
||||
layout.addWidget(header_label)
|
||||
|
||||
# Description
|
||||
desc_label = QtWidgets.QLabel(
|
||||
"Please review the configuration and connection status of all devices before uploading to BEC Server."
|
||||
)
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setStyleSheet("color: #666; margin-bottom: 16px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Config validation section
|
||||
sections_layout = QtWidgets.QHBoxLayout()
|
||||
self.config_section = ValidationSection("Configuration Validation")
|
||||
sections_layout.addWidget(self.config_section)
|
||||
layout.addLayout(sections_layout)
|
||||
|
||||
# Action buttons section
|
||||
self._setup_action_buttons(layout)
|
||||
|
||||
# Dialog buttons
|
||||
self._setup_dialog_buttons(layout)
|
||||
self.adjustSize()
|
||||
|
||||
def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout):
|
||||
"""Setup the action buttons section."""
|
||||
action_group = QtWidgets.QGroupBox("Actions")
|
||||
action_layout = QtWidgets.QVBoxLayout(action_group)
|
||||
|
||||
# Validate connections button
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
self.validate_connections_btn = QtWidgets.QPushButton("Validate All Connections")
|
||||
self.validate_connections_btn.setIcon(self._label_icons["reload"])
|
||||
self.validate_connections_btn.clicked.connect(self._validate_connections)
|
||||
button_layout.addWidget(self.validate_connections_btn)
|
||||
button_layout.addStretch()
|
||||
button_layout.addSpacing(16)
|
||||
action_layout.addLayout(button_layout)
|
||||
|
||||
# Status indicator
|
||||
status_layout = QtWidgets.QHBoxLayout()
|
||||
self.status_icon = QtWidgets.QPushButton()
|
||||
self.status_icon.setFlat(True)
|
||||
self.status_icon.setEnabled(False)
|
||||
self.status_icon.setStyleSheet(self._transparent_button_style)
|
||||
self.status_icon.setFixedSize(24, 24)
|
||||
self.status_label = QtWidgets.QLabel()
|
||||
self.status_label.setWordWrap(True)
|
||||
status_layout.addWidget(self.status_icon)
|
||||
status_layout.addWidget(self.status_label)
|
||||
action_layout.addLayout(status_layout)
|
||||
|
||||
parent_layout.addWidget(action_group)
|
||||
|
||||
def _setup_dialog_buttons(self, parent_layout: QtWidgets.QLayout):
|
||||
"""Setup the dialog buttons."""
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
# Upload button
|
||||
self.upload_btn = QtWidgets.QPushButton("Upload to BEC Server")
|
||||
self.upload_btn.setIcon(self._label_icons["upload"])
|
||||
self.upload_btn.clicked.connect(self._handle_upload)
|
||||
button_layout.addWidget(self.upload_btn)
|
||||
|
||||
parent_layout.addLayout(button_layout)
|
||||
|
||||
def _populate_device_data(self):
|
||||
"""Populate the dialog with device configuration data."""
|
||||
if not self.device_configs:
|
||||
return
|
||||
|
||||
self.has_invalid_configs = 0
|
||||
self.has_untested_connections = 0
|
||||
self.has_cannot_connect = 0
|
||||
|
||||
for device_name, (config, config_status, connection_status) in self.device_configs.items():
|
||||
# Add to appropriate sections
|
||||
self.config_section.add_device(config, config_status, connection_status)
|
||||
|
||||
# Track statistics
|
||||
if config_status == ConfigStatus.INVALID.value:
|
||||
self.has_invalid_configs += 1
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
self.has_untested_connections += 1
|
||||
if connection_status == ConnectionStatus.CANNOT_CONNECT.value:
|
||||
self.has_cannot_connect += 1
|
||||
|
||||
# Update section summaries
|
||||
num_devices = len(self.device_configs)
|
||||
|
||||
# Config validation summary
|
||||
if self.has_invalid_configs > 0:
|
||||
icon = self._label_icons["error"]
|
||||
text = f"{self.has_invalid_configs} of {num_devices} device configurations are invalid."
|
||||
else:
|
||||
icon = self._label_icons["success"]
|
||||
text = f"All {num_devices} device configurations are valid."
|
||||
if self.has_untested_connections > 0:
|
||||
icon = self._label_icons["warning"]
|
||||
text += f"{self.has_untested_connections} device connections are not tested."
|
||||
if self.has_cannot_connect > 0:
|
||||
icon = self._label_icons["warning"]
|
||||
text += f"{self.has_cannot_connect} device connections cannot be established."
|
||||
self.config_section.update_summary(text, icon)
|
||||
|
||||
def _update_ui(self):
|
||||
"""Update UI state based on validation results."""
|
||||
# Update first the device data
|
||||
self._populate_device_data()
|
||||
|
||||
# Invalid configuration have highest priority, upload disabled
|
||||
if self.has_invalid_configs:
|
||||
self.status_icon.setIcon(self._label_icons["error"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_invalid_configs} device configurations are invalid.",
|
||||
"Please fix configuration errors before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setText("Invalid Configurations")
|
||||
|
||||
# Next priority: connections that cannot be established, error but upload is enabled
|
||||
elif self.has_cannot_connect:
|
||||
self.status_icon.setIcon(self._label_icons["warning"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_cannot_connect} connections cannot be established.",
|
||||
"Please fix connection issues before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setText(
|
||||
f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections"
|
||||
)
|
||||
|
||||
# Next priority: untested connections, warning but upload is enabled
|
||||
elif self.has_untested_connections:
|
||||
self.status_icon.setIcon(self._label_icons["warning"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_untested_connections} connections have not been tested.",
|
||||
"Consider validating connections before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setText(
|
||||
f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections"
|
||||
)
|
||||
|
||||
# All good, upload enabled
|
||||
else:
|
||||
self.status_icon.setIcon(self._label_icons["success"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
"All device configurations are valid.",
|
||||
"All connections have been successfully tested.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setText("All Connections Validated")
|
||||
|
||||
@SafeSlot()
|
||||
def _validate_connections(self):
|
||||
"""Request validation of all untested connections. This will close the dialog."""
|
||||
testable_devices: List[dict] = []
|
||||
for _, (config, _, connection_status) in self.device_configs.items():
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
testable_devices.append(config)
|
||||
elif connection_status == ConnectionStatus.CANNOT_CONNECT.value:
|
||||
testable_devices.append(config)
|
||||
|
||||
if len(testable_devices) > 0:
|
||||
self.request_ophyd_validation.emit(testable_devices, True, True)
|
||||
self.done(self.UploadAction.CONNECTION_TEST_REQUESTED)
|
||||
|
||||
@SafeSlot()
|
||||
def _handle_upload(self):
|
||||
"""Handle the upload button click with appropriate confirmations."""
|
||||
# First priority: invalid configurations, block upload
|
||||
if self.has_invalid_configs:
|
||||
detailed_text = (
|
||||
f"There is {self.has_invalid_configs} device with an invalid configuration."
|
||||
if self.has_invalid_configs == 1
|
||||
else f"There are {self.has_invalid_configs} devices with invalid configurations."
|
||||
)
|
||||
text = " ".join(
|
||||
[detailed_text, "Invalid configuration can not be uploaded to the BEC Server."]
|
||||
)
|
||||
QtWidgets.QMessageBox.critical(self, "Device Configurations Invalid", text)
|
||||
self.done(self.UploadAction.CANCEL)
|
||||
return
|
||||
|
||||
# Next priority: connections that cannot be established, show warning, but allow to proceed
|
||||
if self.has_cannot_connect:
|
||||
detailed_text = (
|
||||
f"There is {self.has_cannot_connect} device that cannot connect"
|
||||
if self.has_cannot_connect == 1
|
||||
else f"There are {self.has_cannot_connect} devices that cannot connect."
|
||||
)
|
||||
text = " ".join(
|
||||
[
|
||||
detailed_text,
|
||||
"These devices may not be reachable and disabled BEC upon loading the config.",
|
||||
"Consider validating these connections before proceeding.\n\n",
|
||||
"Continue anyway?",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.critical(
|
||||
self,
|
||||
"Devices cannot Connect",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.No,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
|
||||
# If some connections are untested, warn the user
|
||||
if self.has_untested_connections:
|
||||
detailed_text = (
|
||||
f"There is {self.has_untested_connections} device with untested connections."
|
||||
if self.has_untested_connections == 1
|
||||
else f"There are {self.has_untested_connections} devices with untested connections."
|
||||
)
|
||||
text = " ".join(
|
||||
[
|
||||
detailed_text,
|
||||
"Uploading without validating connections may result in devices that cannot be reached when the configuration is applied.",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Untested Connections",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.No,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
|
||||
# Final confirmation
|
||||
text = " ".join(
|
||||
["You are about to upload the device configurations to BEC Server.", "Please confirm."]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Upload to BEC Server",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.Yes,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.Yes:
|
||||
self.done(self.UploadAction.OK)
|
||||
else:
|
||||
self.done(self.UploadAction.CANCEL)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def _update_from_ophyd_device_tests(
|
||||
self,
|
||||
device_config: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
validation_message: str = "",
|
||||
):
|
||||
"""
|
||||
Update device status from ophyd device tests. This has to be with a connection_status that was updated.
|
||||
|
||||
"""
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
return
|
||||
self.update_device_status(device_config, config_status, connection_status)
|
||||
|
||||
@SafeSlot(list)
|
||||
def _multiple_updates_from_ophyd_device_tests(self, validation_results: _ValidationResultIter):
|
||||
"""
|
||||
Callback slot for receiving multiple validation result updates from the ophyd test widget.
|
||||
|
||||
Args:
|
||||
validation_results (list): List of tuples containing (device_config, config_status, connection_status, validation_msg).
|
||||
"""
|
||||
for cfg, cfg_status, conn_status, val_msg in validation_results:
|
||||
self.update_device_status(cfg, cfg_status, conn_status)
|
||||
self._update_ui()
|
||||
|
||||
@SafeSlot(dict, int, int)
|
||||
def update_device_status(self, device_config: dict, config_status: int, connection_status: int):
|
||||
"""Update the status of a specific device."""
|
||||
# Update device config status
|
||||
self._update_device_configs(device_config, config_status, connection_status, "")
|
||||
# Recalculate summaries and UI state
|
||||
self._update_ui()
|
||||
|
||||
def _update_device_configs(
|
||||
self,
|
||||
device_config: dict[str, Any],
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
validation_msg: str,
|
||||
):
|
||||
device_name = device_config.get("name", "")
|
||||
old_config, _, _ = self.device_configs.get(device_name, (None, None, None))
|
||||
if old_config is not None:
|
||||
self.device_configs[device_name] = (device_config, config_status, connection_status)
|
||||
else:
|
||||
# If device not found, add it
|
||||
self.config_section.add_device(device_config, config_status, connection_status)
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
"""Test the upload redis dialog."""
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Sample device configurations for testing
|
||||
sample_configs = [
|
||||
(
|
||||
{"name": "motor_x", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_1", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_2", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_y", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_z", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_x1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_11", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_21", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.INVALID.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_y1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_z1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
]
|
||||
configs = {cfg[0]["name"]: cfg for cfg in sample_configs}
|
||||
apply_theme("dark")
|
||||
dialog = UploadRedisDialog(parent=None, device_configs=configs)
|
||||
dialog.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,864 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, List, Literal, get_args
|
||||
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction, ScanStatusMessage
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
|
||||
ConfigChoiceDialog,
|
||||
DeviceFormDialog,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
|
||||
UploadRedisDialog,
|
||||
)
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
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.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTable,
|
||||
DMConfigView,
|
||||
DocstringView,
|
||||
OphydValidation,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
)
|
||||
from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import (
|
||||
DeviceInitializationProgressBar,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
class CustomBusyWidget(QWidget):
|
||||
"""Custom busy widget to show during device config upload."""
|
||||
|
||||
cancel_requested = Signal()
|
||||
|
||||
def __init__(self, parent=None, client: BECClient | None = None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Widgets
|
||||
self.progress = QWidget(parent=self)
|
||||
self.progress_layout = QVBoxLayout(self.progress)
|
||||
self.progress_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.progress_inner = DeviceInitializationProgressBar(parent=self.progress, client=client)
|
||||
self.progress_layout.addWidget(self.progress_inner)
|
||||
self.progress.setMinimumWidth(320)
|
||||
|
||||
# Spinner
|
||||
self.spinner = SpinnerWidget(parent=self)
|
||||
scale = self._ui_scale()
|
||||
spinner_size = int(scale * 0.12) if scale else 1
|
||||
spinner_size = max(32, min(spinner_size, 96))
|
||||
self.spinner.setFixedSize(spinner_size, spinner_size)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = QPushButton("Cancel Upload", parent=self)
|
||||
self.cancel_button.setIcon(material_icon("cancel"))
|
||||
self.cancel_button.clicked.connect(self.cancel_requested.emit)
|
||||
button_height = int(spinner_size * 0.9)
|
||||
button_height = max(36, min(button_height, 72))
|
||||
aspect_ratio = 3.8 # width / height, visually stable for text buttons
|
||||
button_width = int(button_height * aspect_ratio)
|
||||
self.cancel_button.setFixedSize(button_width, button_height)
|
||||
color = get_accent_colors()
|
||||
self.cancel_button.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {color.emergency.name()};
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
""")
|
||||
|
||||
# Layout
|
||||
content_layout = QVBoxLayout(self)
|
||||
content_layout.setContentsMargins(24, 24, 24, 24)
|
||||
content_layout.setSpacing(16)
|
||||
content_layout.addStretch()
|
||||
content_layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
content_layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
content_layout.addStretch()
|
||||
content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
if hasattr(color, "_colors"):
|
||||
bg_color = color._colors.get("BG", None)
|
||||
if bg_color is None: # Fallback if missing
|
||||
bg_color = QColor(50, 50, 50, 255)
|
||||
self.setStyleSheet(f"""
|
||||
background-color: {bg_color.name()};
|
||||
border-radius: 12px;
|
||||
""")
|
||||
|
||||
def _ui_scale(self) -> int:
|
||||
parent = self.parent()
|
||||
if not parent:
|
||||
return 0
|
||||
return min(parent.width(), parent.height())
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Show event to start the spinner."""
|
||||
super().showEvent(event)
|
||||
self.spinner.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Hide event to stop the spinner."""
|
||||
super().hideEvent(event)
|
||||
self.spinner.stop()
|
||||
|
||||
|
||||
class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
|
||||
|
||||
RPC = False
|
||||
|
||||
request_ophyd_validation = Signal(list, bool, bool)
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", *args, **kwargs)
|
||||
|
||||
# State variable for config upload
|
||||
self._config_upload_active: bool = False
|
||||
self._config_in_sync: bool = False
|
||||
scan_status = self.bec_dispatcher.client.connector.get(MessageEndpoints.scan_status())
|
||||
initial_status = scan_status.status if scan_status is not None else "closed"
|
||||
self._scan_is_running: bool = initial_status in ["open", "paused"]
|
||||
|
||||
# Push to Redis dialog
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
# NOTE: We need here a seperate config helper instance to avoid conflicts with
|
||||
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
||||
# will block if we use the config_helper from self.client.config._config_helper
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTable(self)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_widget_view = QWidget(self)
|
||||
layout = QVBoxLayout(self.ophyd_widget_view)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
self.ophyd_test_view = OphydValidation(self, hide_legend=False)
|
||||
layout.addWidget(self.ophyd_test_view)
|
||||
|
||||
# Validation Results view
|
||||
self.validation_results = QTextEdit(self)
|
||||
self.validation_results.setReadOnly(True)
|
||||
self.validation_results.setPlaceholderText("Validation results will appear here...")
|
||||
layout.addWidget(self.validation_results)
|
||||
self.ophyd_test_view.item_clicked.connect(self._ophyd_test_item_clicked_cb)
|
||||
|
||||
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.ophyd_test_view.validation_completed,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.multiple_validations_completed,
|
||||
(self.device_table_view.update_multiple_device_validations,),
|
||||
),
|
||||
(self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.ophyd_test_view.device_table_config_changed,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_config_in_sync_with_redis,
|
||||
(self._update_config_in_sync,),
|
||||
),
|
||||
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
self._scan_status_callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.SCAN_STATUS, self._update_scan_running
|
||||
)
|
||||
|
||||
# Add toolbar
|
||||
self._add_toolbar()
|
||||
|
||||
# Build dock layout using shared helpers
|
||||
self._build_docks()
|
||||
|
||||
def cleanup(self):
|
||||
self.bec_dispatcher.client.callbacks.remove(self._scan_status_callback_id)
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""If config upload is active when application is exiting, cancel it."""
|
||||
logger.info("Application is quitting, checking for active config upload...")
|
||||
if self._config_upload_active:
|
||||
logger.info("Application is quitting, cancelling active config upload...")
|
||||
self._config_helper.send_config_request(
|
||||
action="cancel", config=None, wait_for_response=True, timeout_s=10
|
||||
)
|
||||
logger.info("Config upload cancelled.")
|
||||
super().closeEvent(event)
|
||||
|
||||
##############################
|
||||
### Custom set busy widget ###
|
||||
##############################
|
||||
|
||||
def create_busy_state_widget(self) -> QWidget:
|
||||
"""Create a custom busy state widget for uploading device configurations."""
|
||||
widget = CustomBusyWidget(parent=self, client=self.client)
|
||||
widget.cancel_requested.connect(self._cancel_device_config_upload)
|
||||
return widget
|
||||
|
||||
def _set_busy_wrapper(self, enabled: bool):
|
||||
"""Thin wrapper around set_busy to flip the state variable."""
|
||||
self._busy_overlay.set_opacity(0.92)
|
||||
self._config_upload_active = enabled
|
||||
self.set_busy(enabled=enabled)
|
||||
|
||||
##############################
|
||||
### Toolbar and Dock setup ###
|
||||
##############################
|
||||
|
||||
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 _build_docks(self) -> None:
|
||||
# Central device table
|
||||
self.device_table_view_dock = self.new(
|
||||
self.device_table_view,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Bottom area: docstrings
|
||||
self.dm_docs_view_dock = self.new(
|
||||
self.dm_docs_view,
|
||||
where="bottom",
|
||||
relative_to=self.device_table_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
# Config view left of docstrings
|
||||
self.dm_config_view_dock = self.new(
|
||||
self.dm_config_view,
|
||||
where="left",
|
||||
relative_to=self.dm_docs_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Right area: ophyd test + validation
|
||||
self.ophyd_test_dock_view = self.new(
|
||||
self.ophyd_widget_view,
|
||||
where="right",
|
||||
relative_to=self.device_table_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
self.set_layout_ratios(splitter_overrides={0: [7, 3], 1: [3, 7]})
|
||||
|
||||
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
|
||||
save_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("save_to_disk", save_to_disk)
|
||||
save_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
io_bundle.add_action("save_to_disk")
|
||||
|
||||
# Add flush config in redis
|
||||
flush_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Flush current config in BEC Server",
|
||||
label_text="Flush loaded Config",
|
||||
)
|
||||
flush_redis.action.triggered.connect(self._flush_redis_action)
|
||||
self.toolbar.components.add_safe("flush_redis", flush_redis)
|
||||
io_bundle.add_action("flush_redis")
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from BEC Server",
|
||||
label_text="Get loaded 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 BEC Server",
|
||||
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 View",
|
||||
)
|
||||
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._run_validate_connection)
|
||||
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)
|
||||
|
||||
######################################
|
||||
### Update button state management ###
|
||||
######################################
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update_scan_running(self, scan_info: dict, _: dict):
|
||||
"""disable editing when scans are running and enable editing when they are finished"""
|
||||
msg = ScanStatusMessage.model_validate(scan_info)
|
||||
self._scan_is_running = msg.status in ["open", "paused"]
|
||||
self._update_config_enabled_button()
|
||||
|
||||
def _update_config_in_sync(self, in_sync: bool):
|
||||
self._config_in_sync = in_sync
|
||||
self._update_config_enabled_button()
|
||||
|
||||
def _update_config_enabled_button(self):
|
||||
action = self.toolbar.components.get_action("update_config_redis")
|
||||
enabled = not self._config_in_sync and not self._scan_is_running
|
||||
action.action.setEnabled(enabled)
|
||||
if enabled: # button is enabled
|
||||
action.action.setToolTip("Push current config to BEC Server")
|
||||
elif self._scan_is_running:
|
||||
action.action.setToolTip("Scan is currently running, config updates disabled.")
|
||||
else:
|
||||
action.action.setToolTip("Current config is in sync with BEC Server, updates disabled.")
|
||||
|
||||
#######################
|
||||
### Action Handlers ###
|
||||
#######################
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _run_validate_connection(self, connect: bool = True):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = list(self.device_table_view.get_selected_device_configs())
|
||||
if not configs:
|
||||
configs = self.device_table_view.get_device_config()
|
||||
# Adjust the state of the icons in the device table view
|
||||
self.device_table_view.update_multiple_device_validations(
|
||||
[
|
||||
(cfg, ConfigStatus.UNKNOWN.value, ConnectionStatus.UNKNOWN.value, "")
|
||||
for cfg in configs
|
||||
]
|
||||
)
|
||||
self.request_ophyd_validation.emit(configs, True, connect)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
config_path = self._get_config_base_path()
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path = self._get_file_path(start_dir, "open_file")
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _get_config_base_path(self) -> str:
|
||||
"""Get the base path for device configurations."""
|
||||
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}"
|
||||
)
|
||||
return config_path
|
||||
|
||||
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
|
||||
ALLOWED_EXTS = [".yaml", ".yml"]
|
||||
filter_str = "YAML files (*.yaml *.yml);;All Files (*)"
|
||||
initial_filter = "YAML files (*.yaml *.yml);;"
|
||||
if mode == "open_file":
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select Config File",
|
||||
dir=start_dir,
|
||||
filter=filter_str,
|
||||
selectedFilter=initial_filter,
|
||||
)
|
||||
else:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Save Config File",
|
||||
dir=start_dir,
|
||||
filter=filter_str,
|
||||
selectedFilter=initial_filter,
|
||||
)
|
||||
if not file_path:
|
||||
return ""
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext.lower() not in ALLOWED_EXTS:
|
||||
file_path += ".yaml"
|
||||
return 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
|
||||
self._open_config_choice_dialog(config)
|
||||
|
||||
def _open_config_choice_dialog(self, config: List[dict]):
|
||||
"""
|
||||
Open a dialog to choose whether to replace or add the loaded config.
|
||||
|
||||
Args:
|
||||
config (List[dict]): List of device configurations loaded from the file.
|
||||
"""
|
||||
if len(self.device_table_view.get_device_config()) == 0:
|
||||
# If no config is composed yet, load directly
|
||||
self.device_table_view.set_device_config(config)
|
||||
return
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
result = dialog.exec()
|
||||
if result == ConfigChoiceDialog.Result.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif result == ConfigChoiceDialog.Result.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
@SafeSlot()
|
||||
def _flush_redis_action(self):
|
||||
"""Action to flush the current config in Redis."""
|
||||
if self.client.device_manager is None:
|
||||
logger.error("No device manager connected, cannot load config from BEC Server.")
|
||||
return
|
||||
if len(self.client.device_manager.devices) == 0:
|
||||
logger.info("No devices in BEC Server, nothing to flush.")
|
||||
QMessageBox.information(
|
||||
self, "No Devices", "There is currently no config loaded on the BEC Server."
|
||||
)
|
||||
return
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Flush BEC Server Config",
|
||||
"Do you really want to flush the current config in BEC Server?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.client.config.reset_config()
|
||||
logger.info("Successfully flushed configuration in BEC Server.")
|
||||
# Check if config is in sync, enable load redis button
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(
|
||||
self.device_table_view._is_config_in_sync_with_redis()
|
||||
)
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
for config, config_status, connnection_status in validation_results.values():
|
||||
if connnection_status == ConnectionStatus.CONNECTED.value:
|
||||
self.device_table_view.update_device_validation(
|
||||
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
||||
)
|
||||
|
||||
@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."""
|
||||
if self.client.device_manager is None:
|
||||
logger.error("No device manager connected, cannot load config from BEC Server.")
|
||||
return
|
||||
if not self.device_table_view.get_device_config():
|
||||
# If no config is composed yet, load directly
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
return
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config in BEC Server",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
|
||||
"""Action to push the current composition to Redis using the upload dialog."""
|
||||
# Check if validations are still running
|
||||
if self.ophyd_test_view.running_ophyd_tests is True:
|
||||
return QMessageBox.warning(
|
||||
self, "Validation in Progress", "Please wait for the validation to finish."
|
||||
)
|
||||
|
||||
# Get all device configurations with their validation status
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
# Create and show upload dialog
|
||||
self._upload_redis_dialog = UploadRedisDialog(
|
||||
parent=self, device_configs=validation_results
|
||||
)
|
||||
self._upload_redis_dialog.request_ophyd_validation.connect(
|
||||
self.request_ophyd_validation.emit
|
||||
)
|
||||
|
||||
# Show dialog
|
||||
reply = self._upload_redis_dialog.exec_()
|
||||
|
||||
if reply == UploadRedisDialog.UploadAction.OK:
|
||||
self._push_composition_to_redis(action="set")
|
||||
elif reply == UploadRedisDialog.UploadAction.CANCEL:
|
||||
self.ophyd_test_view.cancel_all_validations()
|
||||
elif reply == UploadRedisDialog.UploadAction.CONNECTION_TEST_REQUESTED:
|
||||
return QMessageBox.information(
|
||||
self, "Connection Test Requested", "Running connection test on untested devices."
|
||||
)
|
||||
|
||||
def _push_composition_to_redis(self, action: ConfigAction):
|
||||
"""Push the current device composition to Redis."""
|
||||
if action not in get_args(ConfigAction):
|
||||
logger.error(f"Invalid config action: {action} for uploading to BEC Server.")
|
||||
return
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, config, action)
|
||||
comm.signals.done.connect(self._handle_push_complete_to_communicator)
|
||||
comm.signals.error.connect(self._handle_exception_from_communicator)
|
||||
threadpool.start(comm)
|
||||
self._set_busy_wrapper(enabled=True)
|
||||
|
||||
def _cancel_device_config_upload(self):
|
||||
"""Cancel the device configuration upload process."""
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel")
|
||||
# Cancelling will raise an exception in the communicator, so we connect to the failure handler
|
||||
comm.signals.error.connect(self._handle_cancel_config_upload_failed)
|
||||
threadpool.start(comm)
|
||||
|
||||
def _handle_cancel_config_upload_failed(self, exception: Exception):
|
||||
"""Handle failure to cancel the config upload."""
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
devices_to_update = []
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
devices_to_update.append(
|
||||
(config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled")
|
||||
)
|
||||
# Rerun validation of all devices after cancellation
|
||||
self.device_table_view.update_multiple_device_validations(devices_to_update)
|
||||
self.ophyd_test_view.change_device_configs(
|
||||
[cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False
|
||||
)
|
||||
# Config is in sync with BEC, so we update the state
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(False)
|
||||
|
||||
def _handle_push_complete_to_communicator(self):
|
||||
"""Handle completion of the config push to Redis."""
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
def _handle_exception_from_communicator(self, exception: Exception):
|
||||
"""Handle exceptions from the config communicator."""
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Error Uploading Config",
|
||||
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
|
||||
)
|
||||
self._set_busy_wrapper(enabled=False)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'save_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 = self._get_file_path(config_path, "save_file")
|
||||
if file_path:
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
if os.path.exists(file_path):
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Overwrite File",
|
||||
f"The file '{file_path}' already exists. Do you want to overwrite it?",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
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()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _edit_device_action(self, device_config: dict):
|
||||
"""Action to edit a selected device configuration."""
|
||||
dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes")
|
||||
dialog.accepted_data.connect(self._update_device_to_table_from_dialog)
|
||||
dialog.set_device_config(device_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device")
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _update_device_to_table_from_dialog(
|
||||
self,
|
||||
data: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
if old_device_name and old_device_name != data.get("name", ""):
|
||||
self.device_table_view.remove_device(old_device_name)
|
||||
self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name)
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _add_to_table_from_dialog(
|
||||
self,
|
||||
data: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
self.device_table_view.update_device_configs([data], skip_validation=False)
|
||||
else: # Connection status was tested in dialog
|
||||
# If device is connected, we remove it from the ophyd validation view
|
||||
self.device_table_view.update_device_configs([data], skip_validation=True)
|
||||
# Update validation status in device table view and ophyd validation view
|
||||
self.ophyd_test_view._on_device_test_completed(
|
||||
data, config_status, connection_status, msg
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
configs = self.device_table_view.get_selected_device_configs()
|
||||
if not configs:
|
||||
QMessageBox.warning(
|
||||
self, "No devices selected", "Please select devices from the table to remove."
|
||||
)
|
||||
return
|
||||
if self.device_table_view._remove_configs_dialog([cfg["name"] for cfg in configs]):
|
||||
self.device_table_view.remove_device_configs(configs)
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _ophyd_test_item_clicked_cb(
|
||||
self, device_config: dict, config_status: int, connection_status: int, msg: str, md_msg: str
|
||||
) -> None:
|
||||
self.validation_results.setMarkdown(md_msg)
|
||||
|
||||
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__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
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 = DeviceManagerDisplayWidget()
|
||||
l.addWidget(device_manager_view)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,188 +0,0 @@
|
||||
"""Module for Device Manager View."""
|
||||
|
||||
from qtpy.QtCore import QRect
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class DeviceManagerView(ViewBase):
|
||||
"""
|
||||
A view for users to manage devices within the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
content=content,
|
||||
view_id=view_id,
|
||||
title=title,
|
||||
rpc_passthrough_children=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.device_manager_widget = DeviceManagerWidget(
|
||||
parent=self, rpc_exposed=False, rpc_passthrough_children=False
|
||||
)
|
||||
self.set_content(self.device_manager_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
self.device_manager_widget.on_enter()
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register Device Manager components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: Model containing view title and step IDs.
|
||||
"""
|
||||
step_ids = []
|
||||
dm_widget = self.device_manager_widget
|
||||
|
||||
# The device_manager_widget is not yet initialized, so we will register
|
||||
# tour steps for its uninitialized state.
|
||||
|
||||
# Register Load Current Config button
|
||||
def get_load_current():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is True:
|
||||
return (None, None)
|
||||
return (dm_widget.button_load_current_config, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_load_current,
|
||||
title="Load Current Config",
|
||||
text="Load the current device configuration from the BEC server.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
# Register Load Config From File button
|
||||
def get_load_file():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is True:
|
||||
return (None, None)
|
||||
return (dm_widget.button_load_config_from_file, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_load_file,
|
||||
title="Load Config From File",
|
||||
text="Load a device configuration from a YAML file on disk.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
## Register steps for the initialized state
|
||||
# Register main device table
|
||||
def get_device_table():
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is False:
|
||||
return (None, None)
|
||||
return (dm_widget.device_manager_display.device_table_view, None)
|
||||
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=get_device_table,
|
||||
title="Device Table",
|
||||
text="This table displays the config that is prepared to be uploaded to the BEC server. It allows users to review and modify device config settings, and also validate them before uploading to the BEC server.",
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
col_text_mapping = {
|
||||
0: "Shows if a device configuration is valid. Automatically validated when adding a new device.",
|
||||
1: "Shows if a device is connectable. Validated on demand.",
|
||||
2: "Device name, unique across all devices within a config.",
|
||||
3: "Device class used to initialize the device on the BEC server.",
|
||||
4: "Defines how BEC treats readings of the device during scans. The options are 'monitored', 'baseline', 'async', 'continuous' or 'on_demand'.",
|
||||
5: "Defines how BEC reacts if a device readback fails. Options are 'raise', 'retry', or 'buffer'.",
|
||||
6: "User-defined tags associated with the device.",
|
||||
7: "A brief description of the device.",
|
||||
8: "Device is enabled when the configuration is loaded.",
|
||||
9: "Device is set to read-only.",
|
||||
10: "This flag allows to configure if the 'trigger' method of the device is called during scans.",
|
||||
}
|
||||
|
||||
# We have at least one device registered
|
||||
def get_device_table_row(column: int):
|
||||
main_app.set_current("device_manager")
|
||||
if dm_widget._initialized is False:
|
||||
return (None, None)
|
||||
table = dm_widget.device_manager_display.device_table_view.table
|
||||
header = table.horizontalHeader()
|
||||
x = header.sectionViewportPosition(column)
|
||||
table.horizontalScrollBar().setValue(x)
|
||||
# Recompute after scrolling
|
||||
x = header.sectionViewportPosition(column)
|
||||
w = header.sectionSize(column)
|
||||
h = header.height()
|
||||
rect = QRect(x, 0, w, h)
|
||||
top_left = header.viewport().mapTo(main_app, rect.topLeft())
|
||||
|
||||
return (QRect(top_left, rect.size()), col_text_mapping.get(column, ""))
|
||||
|
||||
for col, text in col_text_mapping.items():
|
||||
step_id = guided_tour.register_widget(
|
||||
widget=lambda col=col: get_device_table_row(col),
|
||||
title=f"{dm_widget.device_manager_display.device_table_view.table.horizontalHeaderItem(col).text()}",
|
||||
text=text,
|
||||
)
|
||||
step_ids.append(step_id)
|
||||
|
||||
if not step_ids:
|
||||
return None
|
||||
|
||||
return ViewTourSteps(view_title="Device Manager", step_ids=step_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
device_manager_view = DeviceManagerView()
|
||||
_app.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
view_id="device_manager",
|
||||
widget=device_manager_view.device_manager_widget,
|
||||
mini_text="DM",
|
||||
)
|
||||
_app.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,112 +0,0 @@
|
||||
"""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_display_widget import (
|
||||
DeviceManagerDisplayWidget,
|
||||
)
|
||||
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):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
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_display = DeviceManagerDisplayWidget(parent=self, client=self.client)
|
||||
self.stacked_layout.addWidget(self.device_manager_display)
|
||||
|
||||
# Add overlay widget
|
||||
self._overlay_widget = QtWidgets.QWidget(self)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self._initialized = False
|
||||
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the widget becomes visible."""
|
||||
if self._initialized is False:
|
||||
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."""
|
||||
self.device_manager_display._load_file_action()
|
||||
self._initialized = True # Set initialized to True after first load
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_display)
|
||||
|
||||
@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_display.device_table_view.set_device_config(config)
|
||||
self._initialized = True # Set initialized to True after first load
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_display)
|
||||
|
||||
|
||||
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_())
|
||||
@@ -1,31 +0,0 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockAreaView(ViewBase):
|
||||
"""
|
||||
Modular dock area view for arranging and managing multiple dockable widgets.
|
||||
"""
|
||||
|
||||
RPC_CONTENT_CLASS = BECDockArea
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
||||
self.dock_area = BECDockArea(
|
||||
self,
|
||||
profile_namespace="bec",
|
||||
auto_profile_namespace=False,
|
||||
object_name="DockArea",
|
||||
rpc_exposed=False,
|
||||
)
|
||||
self.set_content(self.dock_area)
|
||||
@@ -1,320 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
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 ViewTourSteps(BaseModel):
|
||||
"""Model representing tour steps for a view.
|
||||
|
||||
Attributes:
|
||||
view_title: The human-readable title of the view.
|
||||
step_ids: List of registered step IDs in the order they should appear.
|
||||
"""
|
||||
|
||||
view_title: str
|
||||
step_ids: List[str]
|
||||
|
||||
|
||||
class ViewBase(BECWidget, 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.
|
||||
view_id (str | None): Optional view view_id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
USER_ACCESS = ["activate"]
|
||||
RPC_CONTENT_CLASS: type[QWidget] | None = None
|
||||
RPC_CONTENT_ATTR = "content"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
view_id: str | None = None,
|
||||
title: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = view_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
|
||||
|
||||
@SafeSlot()
|
||||
def activate(self) -> None:
|
||||
"""Switch the parent application to this view."""
|
||||
if not self.view_id:
|
||||
raise ValueError("Cannot switch view without a view_id.")
|
||||
|
||||
parent = self.parent()
|
||||
while parent is not None:
|
||||
if hasattr(parent, "set_current"):
|
||||
parent.set_current(self.view_id)
|
||||
return
|
||||
parent = parent.parent()
|
||||
raise RuntimeError("Could not find a parent application with set_current().")
|
||||
|
||||
def cleanup(self):
|
||||
if self.content is not None:
|
||||
self.content.close()
|
||||
self.content.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
||||
"""Register this view's components with the guided tour.
|
||||
|
||||
Args:
|
||||
guided_tour: The GuidedTour instance to register with.
|
||||
main_app: The main application instance (for accessing set_current).
|
||||
|
||||
Returns:
|
||||
ViewTourSteps | None: A model containing the view title and step IDs,
|
||||
or None if this view has no tour steps.
|
||||
|
||||
Override this method in subclasses to register view-specific components.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# 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.signal_edit = SignalComboBox(parent=self)
|
||||
self.signal_edit.include_config_signals = False
|
||||
self.signal_edit.insertItem(0, "")
|
||||
self.signal_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.signal_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.signal_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.signal_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(
|
||||
device_y=self.device_edit.currentText(), signal_y=self.signal_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(device_y=dev, signal_y=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()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 437 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
98
bec_widgets/cli.py
Normal file
98
bec_widgets/cli.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import argparse
|
||||
import os
|
||||
from threading import RLock
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QApplication, QMainWindow
|
||||
from scan_plot import BECScanPlot
|
||||
|
||||
from bec_lib.core import BECMessage, MessageEndpoints, RedisConnector
|
||||
|
||||
|
||||
class BEC_UI(QMainWindow):
|
||||
new_scan_data = pyqtSignal(dict)
|
||||
new_dap_data = pyqtSignal(dict) # signal per proc instance?
|
||||
new_scan = pyqtSignal()
|
||||
|
||||
def __init__(self, uipath):
|
||||
super().__init__()
|
||||
self._scan_channels = set()
|
||||
self._dap_channels = set()
|
||||
|
||||
self._scan_thread = None
|
||||
self._dap_threads = []
|
||||
|
||||
ui = uic.loadUi(uipath, self)
|
||||
|
||||
_, fname = os.path.split(uipath)
|
||||
self.setWindowTitle(fname)
|
||||
|
||||
for sp in ui.findChildren(BECScanPlot):
|
||||
for chan in (sp.x_channel, *sp.y_channel_list):
|
||||
if chan.startswith("dap."):
|
||||
chan = chan.partition("dap.")[-1]
|
||||
self._dap_channels.add(chan)
|
||||
else:
|
||||
self._scan_channels.add(chan)
|
||||
|
||||
sp.initialize() # TODO: move this elsewhere?
|
||||
|
||||
self.new_scan_data.connect(sp.redraw_scan) # TODO: merge
|
||||
self.new_dap_data.connect(sp.redraw_dap)
|
||||
self.new_scan.connect(sp.clearData)
|
||||
|
||||
# Scan setup
|
||||
self._scan_id = None
|
||||
scan_lock = RLock()
|
||||
|
||||
def _scan_cb(msg):
|
||||
msg = BECMessage.ScanMessage.loads(msg.value)
|
||||
with scan_lock:
|
||||
scan_id = msg[0].content["scanID"]
|
||||
if self._scan_id != scan_id:
|
||||
self._scan_id = scan_id
|
||||
self.new_scan.emit()
|
||||
self.new_scan_data.emit(msg[0].content["data"])
|
||||
|
||||
bec_connector = RedisConnector("localhost:6379")
|
||||
|
||||
if self._scan_channels:
|
||||
scan_readback = MessageEndpoints.scan_segment()
|
||||
self._scan_thread = bec_connector.consumer(
|
||||
topics=scan_readback,
|
||||
cb=_scan_cb,
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
# DAP setup
|
||||
def _proc_cb(msg):
|
||||
msg = BECMessage.ProcessedDataMessage.loads(msg.value)
|
||||
self.new_dap_data.emit(msg.content["data"])
|
||||
|
||||
if self._dap_channels:
|
||||
for chan in self._dap_channels:
|
||||
proc_ep = MessageEndpoints.processed_data(chan)
|
||||
dap_thread = bec_connector.consumer(topics=proc_ep, cb=_proc_cb)
|
||||
dap_thread.start()
|
||||
self._dap_threads.append(dap_thread)
|
||||
|
||||
self.show()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="bec-pyqt", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument("uipath", type=str, help="Path to a BEC ui file")
|
||||
|
||||
args, rem = parser.parse_known_args()
|
||||
|
||||
app = QApplication(rem)
|
||||
BEC_UI(args.uipath)
|
||||
app.exec_()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,665 +0,0 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
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
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
str | bool | dict,
|
||||
]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
|
||||
|
||||
def _filter_output(output: str) -> str:
|
||||
"""
|
||||
Filter out the output from the process.
|
||||
"""
|
||||
if "IMKClient" in output:
|
||||
# only relevant on macOS
|
||||
# see https://discussions.apple.com/thread/255761734?sortBy=rank
|
||||
return ""
|
||||
return output
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
buf = stream_buffer[stream]
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
output = _filter_output(output)
|
||||
if output:
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
buf.append(remaining)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(
|
||||
gui_id: str,
|
||||
gui_class_id: str,
|
||||
config: dict | str,
|
||||
gui_class: str = "dock_area",
|
||||
logger=None, # FIXME change gui_class back to "launcher" later
|
||||
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
Logger must be a logger object with "debug" and "error" functions,
|
||||
or it can be left to "None" as default. None means output from the
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
command.extend(["--config", str(config)])
|
||||
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
|
||||
if logger is None:
|
||||
stdout_redirect = subprocess.DEVNULL
|
||||
stderr_redirect = subprocess.DEVNULL
|
||||
else:
|
||||
stdout_redirect = subprocess.PIPE
|
||||
stderr_redirect = subprocess.PIPE
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
stdout=stdout_redirect,
|
||||
stderr=stderr_redirect,
|
||||
env=env_dict,
|
||||
)
|
||||
if logger is None:
|
||||
process_output_processing_thread = None
|
||||
else:
|
||||
process_output_processing_thread = threading.Thread(
|
||||
target=_get_output, args=(process, logger)
|
||||
)
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
class RepeatTimer(threading.Timer):
|
||||
"""RepeatTimer class."""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@contextmanager
|
||||
def wait_for_server(client: BECGuiClient):
|
||||
"""Context manager to wait for the server to start."""
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client._gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
if client._gui_started_timer is not None:
|
||||
# cancel the timer, we are done
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
# after initial waiting period, do not wait so much any more
|
||||
# (only relevant if GUI didn't start)
|
||||
client._startup_timeout = 0
|
||||
yield
|
||||
|
||||
|
||||
class WidgetNameSpace:
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr, value in self.__dict__.items():
|
||||
docs = value.__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr, docs)
|
||||
console.print(table)
|
||||
return ""
|
||||
|
||||
|
||||
class AvailableWidgetsNamespace:
|
||||
"""Namespace for available widgets in the BEC GUI."""
|
||||
|
||||
def __init__(self):
|
||||
for widget in client.Widgets:
|
||||
name = widget.value
|
||||
if name in IGNORE_WIDGETS:
|
||||
continue
|
||||
setattr(self, name, name)
|
||||
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr_name, _ in self.__dict__.items():
|
||||
docs = getattr(client, attr_name).__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
|
||||
console.print(table)
|
||||
return ""
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
"""BEC GUI client class. Container for GUI applications within Python."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._lock = Lock()
|
||||
self._anchor_widget = "launcher"
|
||||
self._killed = False
|
||||
self._top_level: dict[str, RPCReference] = {}
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
self._server_registry: dict[str, RegistryState] = {}
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
####################
|
||||
|
||||
@property
|
||||
def launcher(self) -> RPCBase:
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
|
||||
def connect_to_gui_server(self, gui_id: str) -> None:
|
||||
"""Connect to a GUI server"""
|
||||
# Unregister the old callback
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
self._gui_id = gui_id
|
||||
|
||||
# reset the namespace
|
||||
self._update_dynamic_namespace({})
|
||||
self._server_registry = {}
|
||||
self._top_level = {}
|
||||
self._ipython_registry = {}
|
||||
|
||||
# Register the new callback
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock areas in the GUI."""
|
||||
return {widget.object_name: widget for widget in self._top_level.values()}
|
||||
|
||||
@property
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
|
||||
return self._start(wait=wait)
|
||||
|
||||
def show(self, wait=True) -> None:
|
||||
"""
|
||||
Show the GUI window.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
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 change_theme(self, theme: Literal["light", "dark"] | None = None) -> None:
|
||||
"""
|
||||
Apply a GUI theme or toggle between dark and light.
|
||||
|
||||
Args:
|
||||
theme(Literal["light", "dark"] | None): Theme to apply. If None, the current
|
||||
theme is fetched from the GUI and toggled.
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self._start(wait=True)
|
||||
|
||||
with wait_for_server(self):
|
||||
if theme is None:
|
||||
current_theme = self.launcher._run_rpc("fetch_theme")
|
||||
next_theme = "light" if current_theme == "dark" else "dark"
|
||||
else:
|
||||
next_theme = theme
|
||||
self.launcher._run_rpc("change_theme", theme=next_theme)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
launch_script: str = "dock_area",
|
||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
||||
**kwargs,
|
||||
) -> client.AdvancedDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
|
||||
Args:
|
||||
name(str, optional): The name of the dock area. Defaults to None.
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
|
||||
launch_script(str): The launch script to use. Defaults to "dock_area".
|
||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
||||
the dock area:
|
||||
- None: start in transient empty workspace
|
||||
- "restore": restore last-used profile
|
||||
- "skip": skip profile initialization
|
||||
- "<name>": load the named profile
|
||||
**kwargs: Additional keyword arguments passed to the dock area.
|
||||
|
||||
Returns:
|
||||
client.AdvancedDockArea: The new dock area.
|
||||
|
||||
Examples:
|
||||
>>> gui.new() # Start with an empty unsaved workspace
|
||||
>>> gui.new(startup_profile="restore") # Restore last profile
|
||||
>>> gui.new(startup_profile="my_profile") # Load explicit profile
|
||||
"""
|
||||
if "profile" in kwargs or "start_empty" in kwargs:
|
||||
raise TypeError(
|
||||
"gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead."
|
||||
)
|
||||
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
return self._new_impl(
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
launch_script=launch_script,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
return self._new_impl(
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
launch_script=launch_script,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _new_impl(
|
||||
self,
|
||||
*,
|
||||
name: str | None,
|
||||
geometry: tuple[int, int, int, int] | None,
|
||||
launch_script: str,
|
||||
startup_profile: str | Literal["restore", "skip"] | None,
|
||||
**kwargs,
|
||||
):
|
||||
if launch_script == "dock_area":
|
||||
try:
|
||||
return self.launcher._run_rpc(
|
||||
"system.launch_dock_area",
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
)
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
if (
|
||||
"Unknown system RPC method: system.launch_dock_area" not in error
|
||||
and "has no attribute 'system.launch_dock_area'" not in error
|
||||
):
|
||||
raise
|
||||
logger.debug("Server does not support system.launch_dock_area; using launcher RPC")
|
||||
|
||||
return self.launcher._run_rpc(
|
||||
"launch",
|
||||
launch_script=launch_script,
|
||||
name=name,
|
||||
geometry=geometry,
|
||||
startup_profile=startup_profile,
|
||||
**kwargs,
|
||||
) # pylint: disable=protected-access
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area and its parent window.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
"""
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
|
||||
# Get the container_proxy (parent window) gui_id from the server registry
|
||||
obj = self._server_registry.get(widget._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {name} not found in registry.")
|
||||
|
||||
container_gui_id = obj.get("container_proxy")
|
||||
if container_gui_id:
|
||||
# Close the container window which will also clean up the dock area
|
||||
widget._run_rpc("close", gui_id=container_gui_id) # pylint: disable=protected-access
|
||||
else:
|
||||
# Fallback: just close the dock area directly
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
for widget_name in self.windows:
|
||||
self.delete(widget_name)
|
||||
|
||||
def kill_server(self) -> None:
|
||||
"""Kill the GUI server."""
|
||||
# Unregister the registry state
|
||||
self._killed = True
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
self._gui_started_timer.join()
|
||||
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
if self._process:
|
||||
logger.success("Stopping GUI...")
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
# Remove all reference from top level
|
||||
self._top_level.clear()
|
||||
self._server_registry.clear()
|
||||
|
||||
def close(self):
|
||||
"""Deprecated. Use kill_server() instead."""
|
||||
# FIXME, deprecated in favor of kill, will be removed in the future
|
||||
self.kill_server()
|
||||
|
||||
#########################
|
||||
#### Private methods ####
|
||||
#########################
|
||||
|
||||
def _check_if_server_is_alive(self):
|
||||
"""Checks if the process is alive"""
|
||||
if self._process is None:
|
||||
return False
|
||||
if self._process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _gui_post_startup(self):
|
||||
timeout = 60
|
||||
# Wait for 'bec' gui to be registered, this may take some time
|
||||
# After 60s timeout. Should this raise an exception on timeout?
|
||||
start = time.monotonic()
|
||||
while time.monotonic() < start + timeout:
|
||||
if len(list(self._server_registry.keys())) < 2 or not hasattr(
|
||||
self, self._anchor_widget
|
||||
):
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
|
||||
self._gui_started_event.set()
|
||||
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
"""
|
||||
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
|
||||
self._gui_started_event.clear()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id,
|
||||
gui_class_id="bec",
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel() # type: ignore
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def _start(self, wait: bool = False) -> None:
|
||||
self._killed = False
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
@staticmethod
|
||||
def _handle_registry_update(
|
||||
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
||||
) -> None:
|
||||
# This was causing a deadlock during shutdown, not sure why.
|
||||
# with self._lock:
|
||||
self = parent
|
||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _do_raise_all(self):
|
||||
"""Bring GUI windows to the front."""
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
return self._do_raise_all()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
Update the dynamic name space with the given server registry.
|
||||
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
|
||||
|
||||
Args:
|
||||
server_registry (dict): The server registry
|
||||
"""
|
||||
top_level_widgets: dict[str, RPCReference] = {}
|
||||
for gui_id, state in server_registry.items():
|
||||
widget = self._add_widget(state, self)
|
||||
if widget is None:
|
||||
# ignore widgets that are not supported
|
||||
continue
|
||||
# get all top-level widgets. These are widgets that have no parent
|
||||
if not state["config"].get("parent_id"):
|
||||
top_level_widgets[gui_id] = widget
|
||||
|
||||
remove_from_registry = []
|
||||
for gui_id, widget in self._ipython_registry.items():
|
||||
if gui_id not in server_registry:
|
||||
remove_from_registry.append(gui_id)
|
||||
for gui_id in remove_from_registry:
|
||||
self._ipython_registry.pop(gui_id)
|
||||
|
||||
removed_widgets = [
|
||||
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
|
||||
]
|
||||
|
||||
for widget_name in removed_widgets:
|
||||
# the check is not strictly necessary, but better safe
|
||||
# than sorry; who knows what the user has done
|
||||
if hasattr(self, widget_name):
|
||||
delattr(self, widget_name)
|
||||
|
||||
for gui_id, widget_ref in top_level_widgets.items():
|
||||
setattr(self, widget_ref.object_name, widget_ref)
|
||||
|
||||
self._top_level = top_level_widgets
|
||||
|
||||
for widget in self._ipython_registry.values():
|
||||
widget._refresh_references()
|
||||
|
||||
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
|
||||
"""Add a widget to the namespace
|
||||
|
||||
Args:
|
||||
state (dict): The state of the widget from the _server_registry.
|
||||
parent (object): The parent object.
|
||||
"""
|
||||
object_name = state["object_name"]
|
||||
gui_id = state["gui_id"]
|
||||
if state["widget_class"] in IGNORE_WIDGETS:
|
||||
return
|
||||
widget_class = getattr(client, state["widget_class"], None)
|
||||
if widget_class is None:
|
||||
return
|
||||
obj = self._ipython_registry.get(gui_id)
|
||||
if obj is None:
|
||||
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
|
||||
self._ipython_registry[gui_id] = widget
|
||||
else:
|
||||
widget = obj
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
|
||||
return obj
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
|
||||
try:
|
||||
config = ServiceConfig()
|
||||
bec_client = BECClient(config)
|
||||
bec_client.start()
|
||||
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.show(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
gui.kill_server()
|
||||
@@ -1,363 +0,0 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import black
|
||||
import isort
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property as QtProperty
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
print(
|
||||
"Python version is less than 3.11, using dummy function for get_overloads. "
|
||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||
)
|
||||
|
||||
def get_overloads(_obj):
|
||||
"""
|
||||
Dummy function for Python versions before 3.11.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self, base=False):
|
||||
self._base = base
|
||||
base_imports = (
|
||||
"""import enum
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
else "\n"
|
||||
)
|
||||
self.header = f"""# This file was automatically generated by generate_cli.py
|
||||
# type: ignore \n
|
||||
from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes, skipping any classes
|
||||
that have `RPC = False`.
|
||||
|
||||
Args:
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
# Filter out classes that explicitly have RPC=False
|
||||
rpc_top_level_classes = [
|
||||
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
connector_classes = [
|
||||
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
logger.debug(f"generating RPC client class for {cls.__name__}")
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
def write_client_enum(self, published_classes: list[type]):
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||
...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
|
||||
_Widgets = {
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
|
||||
|
||||
self.content += """}
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
globals()[plugin_name] = plugin_class
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||
"""
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
Generate the content for the class.
|
||||
|
||||
Args:
|
||||
cls: The class for which to generate the content.
|
||||
"""
|
||||
|
||||
class_name = cls.__name__
|
||||
|
||||
if class_name == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
# But skip the first line if it's a blank line
|
||||
first_line = cls.__doc__.split("\n")[0]
|
||||
if first_line:
|
||||
class_docs = first_line
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
user_access_entries = self._get_user_access_entries(cls)
|
||||
if not user_access_entries:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method_entry in user_access_entries:
|
||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
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:
|
||||
self.content += f"""
|
||||
@{method}.setter
|
||||
@rpc_call"""
|
||||
else:
|
||||
self.content += """
|
||||
@property
|
||||
@rpc_call"""
|
||||
|
||||
sig = str(inspect.signature(obj.fget))
|
||||
doc = inspect.getdoc(obj.fget)
|
||||
else:
|
||||
sig = str(inspect.signature(obj))
|
||||
doc = inspect.getdoc(obj)
|
||||
overloads = get_overloads(obj)
|
||||
for overload in overloads:
|
||||
sig_overload = str(inspect.signature(overload))
|
||||
self.content += f"""
|
||||
@overload
|
||||
def {method}{str(sig_overload)}: ...
|
||||
"""
|
||||
|
||||
self.content += f"""
|
||||
{self._rpc_call(timeout)}"""
|
||||
self.content += f"""
|
||||
def {method}{str(sig)}:
|
||||
\"\"\"
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
@staticmethod
|
||||
def _get_user_access_entries(cls) -> list[str]:
|
||||
entries = list(getattr(cls, "USER_ACCESS", []))
|
||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is not None:
|
||||
entries.extend(getattr(content_cls, "USER_ACCESS", []))
|
||||
return list(dict.fromkeys(entries))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_method_object(cls, method_entry: str):
|
||||
method_name = method_entry
|
||||
is_property_setter = False
|
||||
|
||||
if method_entry.endswith(".setter"):
|
||||
is_property_setter = True
|
||||
method_name = method_entry.split(".setter")[0]
|
||||
|
||||
candidate_classes = [cls]
|
||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
||||
if content_cls is not None:
|
||||
candidate_classes.append(content_cls)
|
||||
|
||||
for candidate_cls in candidate_classes:
|
||||
obj = getattr(candidate_cls, method_name, None)
|
||||
if obj is not None:
|
||||
return method_name, obj, is_property_setter
|
||||
return method_name, None, is_property_setter
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
file_name(str): The name of the file to write to.
|
||||
"""
|
||||
# Combine header and content, then format with black
|
||||
full_content = self.header + "\n" + self.content
|
||||
try:
|
||||
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
config = isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=False,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="store",
|
||||
type=str,
|
||||
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.target is None:
|
||||
logger.error(
|
||||
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"BEC Widget code generation tool started with args: {args}")
|
||||
|
||||
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
|
||||
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
assert module.__file__ is not None
|
||||
module_file = Path(module.__file__)
|
||||
module_dir = module_file.parent if module_file.is_file() else module_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {module_name} for code generation: {e}")
|
||||
return
|
||||
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
|
||||
packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",)
|
||||
rpc_classes = get_custom_classes(module_name, packages=packages)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
logger.info(f"Generating client file at {client_path}")
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(str(client_path))
|
||||
|
||||
if module_name != "bec_widgets":
|
||||
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
|
||||
logger.info(
|
||||
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
|
||||
)
|
||||
else:
|
||||
non_overwrite_classes = []
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
|
||||
if cls.__name__ in non_overwrite_classes:
|
||||
logger.error(
|
||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||
)
|
||||
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
def _exists(file: str):
|
||||
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
||||
|
||||
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
||||
logger.debug(
|
||||
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
||||
)
|
||||
continue
|
||||
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
|
||||
main()
|
||||
@@ -1,354 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
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
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
else:
|
||||
client = lazy_import("bec_widgets.cli.client") # avoid circular import
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
# 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.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
caller_frame = inspect.currentframe().f_back # type: ignore
|
||||
while caller_frame:
|
||||
if "jedi" in caller_frame.f_globals:
|
||||
# Jedi module is present, likely tab completion
|
||||
# Do not run the RPC call
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
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)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class DeletedWidgetError(Exception): ...
|
||||
|
||||
|
||||
def check_for_deleted_widget(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCReference:
|
||||
def __init__(self, registry: dict, gui_id: str) -> None:
|
||||
self._registry = registry
|
||||
self._gui_id = gui_id
|
||||
self.object_name = self._registry[self._gui_id].object_name
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getattr__(self, name):
|
||||
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
|
||||
return super().__getattribute__(name)
|
||||
return self._registry[self._gui_id].__getattribute__(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
|
||||
return super().__setattr__(name, value)
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
self._registry[self._gui_id].__setattr__(name, value)
|
||||
|
||||
def __repr__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__repr__()
|
||||
|
||||
def __str__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__str__()
|
||||
|
||||
def __dir__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return []
|
||||
return self._registry[self._gui_id].__dir__()
|
||||
|
||||
def _is_deleted(self) -> bool:
|
||||
return self._gui_id not in self._registry
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
config: dict | None = None,
|
||||
object_name: str | None = None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
self._rpc_references: dict[str, str] = {}
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} with name: {self.object_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
if proxy := obj.get("container_proxy"):
|
||||
assert isinstance(proxy, str)
|
||||
self._run_rpc("remove", gui_id=proxy)
|
||||
return
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def _root(self) -> BECGuiClient:
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
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,
|
||||
*args,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
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,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(timeout)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
|
||||
# we can assume that the response is a RequestResponseMessage, updated by
|
||||
# the _on_rpc_response method
|
||||
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
|
||||
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||
parent._rpc_response = msg
|
||||
parent._msg_wait_event.set()
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
rpc_enabled = msg_result.get("__rpc__", True)
|
||||
if rpc_enabled is False:
|
||||
return None
|
||||
|
||||
msg_result = dict(msg_result)
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# The namespace of the object will be updated dynamically on the client side
|
||||
# Therefore it is important to check if the object is already in the registry
|
||||
# If yes, we return the reference to the object, otherwise we create a new object
|
||||
# pylint: disable=protected-access
|
||||
if msg_result["gui_id"] in self._root._ipython_registry:
|
||||
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
|
||||
ret = cls(parent=self, **msg_result)
|
||||
self._root._ipython_registry[ret._gui_id] = ret
|
||||
self._refresh_references()
|
||||
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
|
||||
return obj
|
||||
# return ret
|
||||
return msg_result
|
||||
|
||||
def _gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _refresh_references(self):
|
||||
"""
|
||||
Refresh the references.
|
||||
"""
|
||||
with self._root._lock:
|
||||
references = {}
|
||||
for key, val in self._root._server_registry.items():
|
||||
parent_id = val["config"].get("parent_id")
|
||||
if parent_id == self._gui_id:
|
||||
references[key] = {
|
||||
"gui_id": val["config"]["gui_id"],
|
||||
"object_name": val["object_name"],
|
||||
}
|
||||
removed_references = set(self._rpc_references.keys()) - set(references.keys())
|
||||
for key in removed_references:
|
||||
delattr(self, self._rpc_references[key]["object_name"])
|
||||
self._rpc_references = references
|
||||
for key, val in references.items():
|
||||
setattr(
|
||||
self,
|
||||
val["object_name"],
|
||||
RPCReference(self._root._ipython_registry, val["gui_id"]),
|
||||
)
|
||||
@@ -1,196 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from threading import RLock
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
import shiboken6 as shb
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def broadcast_update(func):
|
||||
"""
|
||||
Decorator to broadcast updates to the RPCRegister whenever a new RPC object is added or removed.
|
||||
If class attribute _skip_broadcast is set to True, the broadcast will be skipped
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
result = func(self, *args, **kwargs)
|
||||
self.broadcast()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RPCRegister, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._broadcast_on_hold = RPCRegisterBroadcast(self)
|
||||
self._lock = RLock()
|
||||
self._skip_broadcast = False
|
||||
self._initialized = True
|
||||
self.callbacks = []
|
||||
|
||||
@classmethod
|
||||
def delayed_broadcast(cls):
|
||||
"""
|
||||
Delay the broadcast of the update to all the callbacks.
|
||||
"""
|
||||
register = cls()
|
||||
return register._broadcast_on_hold
|
||||
|
||||
@broadcast_update
|
||||
def add_rpc(self, rpc: BECConnector):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
Args:
|
||||
rpc(QObject): The RPC object to be added to the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
@broadcast_update
|
||||
def remove_rpc(self, rpc: BECConnector):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
Args:
|
||||
rpc(str): The RPC object to be removed from the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
Args:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject | None: The RPC object with the given ID or None
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
|
||||
def list_all_connections(self) -> dict:
|
||||
"""
|
||||
List all the registered RPC objects.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = {}
|
||||
for gui_id, obj in self._rpc_register.items():
|
||||
try:
|
||||
if not shb.isValid(obj):
|
||||
continue
|
||||
connections[gui_id] = obj
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking validity of object {gui_id}: {e}")
|
||||
continue
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: type[BECWidget] | type[BECConnector]
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
Args:
|
||||
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
|
||||
"""
|
||||
# This retrieves any rpc objects that are subclass of BECWidget,
|
||||
# i.e. curve and image items are excluded
|
||||
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
|
||||
return [widget.object_name for widget in widgets]
|
||||
|
||||
def broadcast(self):
|
||||
"""
|
||||
Broadcast the update to all the callbacks.
|
||||
"""
|
||||
|
||||
if self._skip_broadcast:
|
||||
return
|
||||
connections = self.list_all_connections()
|
||||
for callback in self.callbacks:
|
||||
callback(connections)
|
||||
|
||||
def object_is_registered(self, obj: BECConnector) -> bool:
|
||||
"""
|
||||
Check if an object is registered in the RPC register.
|
||||
|
||||
Args:
|
||||
obj(QObject): The object to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the object is registered, False otherwise.
|
||||
"""
|
||||
return obj.gui_id in self._rpc_register
|
||||
|
||||
def add_callback(self, callback: Callable[[dict], None]):
|
||||
"""
|
||||
Add a callback that will be called whenever the registry is updated.
|
||||
|
||||
Args:
|
||||
callback(Callable[[dict], None]): The callback to be added. It should accept a dictionary of all the
|
||||
registered RPC objects as an argument.
|
||||
"""
|
||||
self.callbacks.append(callback)
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
|
||||
class RPCRegisterBroadcast:
|
||||
"""Context manager for RPCRegister broadcast."""
|
||||
|
||||
def __init__(self, rpc_register: RPCRegister) -> None:
|
||||
self.rpc_register = rpc_register
|
||||
self._call_depth = 0
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter the context manager"""
|
||||
self._call_depth += 1 # Needed for nested calls
|
||||
self.rpc_register._skip_broadcast = True
|
||||
return self.rpc_register
|
||||
|
||||
def __exit__(self, *exc):
|
||||
"""Exit the context manager"""
|
||||
|
||||
self._call_depth -= 1 # Remove nested calls
|
||||
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
|
||||
self.rpc_register._skip_broadcast = False
|
||||
self.rpc_register.broadcast()
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
def __init__(self):
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes # type: ignore
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
Update the available widgets.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
|
||||
+ get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECWidget): The created widget.
|
||||
"""
|
||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
@@ -1,195 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
import darkdetect
|
||||
import shiboken6
|
||||
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
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class SimpleFileLikeFromLogOutputFunc:
|
||||
def __init__(self, log_func):
|
||||
self._log_func = log_func
|
||||
self._buffer = []
|
||||
|
||||
def write(self, buffer):
|
||||
self._buffer.append(buffer)
|
||||
|
||||
def flush(self):
|
||||
lines, _, remaining = "".join(self._buffer).rpartition("\n")
|
||||
if lines:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
class GUIServer:
|
||||
"""
|
||||
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
|
||||
"""
|
||||
|
||||
def __init__(self, args):
|
||||
self.config = args.config
|
||||
self.gui_id = args.id
|
||||
self.gui_class = args.gui_class
|
||||
self.gui_class_id = args.gui_class_id
|
||||
self.hide = args.hide
|
||||
self.app: QApplication | None = None
|
||||
self.launcher_window: LaunchWindow | None = None
|
||||
self.dispatcher: BECDispatcher | None = None
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the GUI server.
|
||||
"""
|
||||
bec_logger.level = bec_logger.LOGLEVEL.INFO
|
||||
if self.hide:
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
|
||||
self._run()
|
||||
|
||||
def _get_service_config(self) -> ServiceConfig:
|
||||
if self.config:
|
||||
try:
|
||||
config = json.loads(self.config)
|
||||
service_config = ServiceConfig(config=config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
service_config = ServiceConfig(config_path=config)
|
||||
else:
|
||||
# if no config is provided, use the default config
|
||||
service_config = ServiceConfig()
|
||||
return service_config
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
Run the GUI server.
|
||||
"""
|
||||
logger.info("Starting GUIServer", repr(self))
|
||||
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.app.gui_server = self # type: ignore # make server accessible from QApplication for getattr in widgets
|
||||
self.setup_bec_icon()
|
||||
|
||||
service_config = self._get_service_config()
|
||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||
|
||||
if self.gui_class:
|
||||
self.launcher_window = LaunchWindow(
|
||||
gui_id=f"{self.gui_id}:launcher",
|
||||
launch_gui_class=self.gui_class,
|
||||
launch_gui_id=self.gui_class_id,
|
||||
)
|
||||
else:
|
||||
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
|
||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# Widgets should be all closed.
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
self.shutdown()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
def setup_bec_icon(self):
|
||||
"""
|
||||
Set the BEC icon for the application
|
||||
"""
|
||||
if self.app is None:
|
||||
return
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def shutdown(self):
|
||||
logger.info("Shutdown GUIServer", repr(self))
|
||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
||||
self.launcher_window.close()
|
||||
self.launcher_window.deleteLater()
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for subprocesses that start a GUI server.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, default="test", help="The id of the server")
|
||||
parser.add_argument(
|
||||
"--gui_class",
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui_class_id",
|
||||
type=str,
|
||||
default="bec",
|
||||
help="The id of the gui class that is added to the QApplication",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config file or config string.")
|
||||
parser.add_argument("--hide", action="store_true", help="Hide on startup")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
server = GUIServer(args)
|
||||
server.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# import sys
|
||||
|
||||
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
|
||||
main()
|
||||
138
bec_widgets/config_plotter.py
Normal file
138
bec_widgets/config_plotter.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtWidgets import QApplication, QGridLayout, QSizePolicy, QWidget
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore
|
||||
|
||||
|
||||
class ConfigPlotter(QWidget):
|
||||
"""
|
||||
ConfigPlotter is a widget that can be used to plot data from multiple channels
|
||||
in a grid layout. The layout is specified by a list of dicts, where each dict
|
||||
specifies the position of the plot in the grid, the channels to plot, and the
|
||||
type of plot to use. The plot type is specified by the name of the pyqtgraph
|
||||
item to use. For example, to plot a single channel in a PlotItem, the config
|
||||
would look like this:
|
||||
|
||||
config = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
}
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, configs: List[dict], parent=None):
|
||||
super(ConfigPlotter, self).__init__()
|
||||
self.configs = configs
|
||||
self.plots = {}
|
||||
self._init_ui()
|
||||
self._init_plots()
|
||||
|
||||
def _init_ui(self):
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
self.layout = QGridLayout()
|
||||
self.setLayout(self.layout)
|
||||
self.pen = mkPen(color=(56, 76, 107), width=4, style=QtCore.Qt.SolidLine)
|
||||
self.show()
|
||||
|
||||
def _init_plots(self):
|
||||
for config in self.configs:
|
||||
channels = config["config"]["channels"]
|
||||
for channel in channels:
|
||||
# call the corresponding init function, e.g. init_plotitem
|
||||
init_func = getattr(self, f"init_{config['config']['item']}")
|
||||
init_func(channel, config)
|
||||
|
||||
# self.init_ImageItem(channel, config["config"], item)
|
||||
|
||||
def init_PlotItem(self, channel: str, config: dict):
|
||||
"""
|
||||
Initialize a PlotItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
plot_widget = pg.PlotWidget()
|
||||
plot_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.layout.addWidget(plot_widget, config["y"], config["x"], config["rows"], config["cols"])
|
||||
plot_data = plot_widget.plot(np.random.rand(100), pen=self.pen)
|
||||
# item.setLabel("left", channel)
|
||||
# self.plots[channel] = {"item": item, "plot_data": plot_data}
|
||||
|
||||
def init_ImageItem(self, channel: str, config: dict):
|
||||
"""
|
||||
Initialize an ImageItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
item = pg.PlotItem()
|
||||
self.layout.addItem(
|
||||
item,
|
||||
row=config["y"],
|
||||
col=config["x"],
|
||||
rowspan=config["rows"],
|
||||
colspan=config["cols"],
|
||||
)
|
||||
img = pg.ImageItem()
|
||||
item.addItem(img)
|
||||
img.setImage(np.random.rand(100, 100))
|
||||
self.plots[channel] = {"item": item, "plot_data": img}
|
||||
|
||||
def init_ImageView(self, channel: str, config: dict):
|
||||
"""
|
||||
Initialize an ImageView
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
img = pg.ImageView()
|
||||
img.setImage(np.random.rand(100, 100))
|
||||
self.layout.addWidget(img, config["y"], config["x"], config["rows"], config["cols"])
|
||||
self.plots[channel] = {"item": img, "plot_data": img}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
CONFIG = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 1,
|
||||
"x": 0,
|
||||
"config": {"channels": ["b"], "label_xy": ["", "b"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 2,
|
||||
"y": 0,
|
||||
"x": 1,
|
||||
"config": {"channels": ["c"], "label_xy": ["", "c"], "item": "ImageView"},
|
||||
},
|
||||
]
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
win = ConfigPlotter(CONFIG)
|
||||
pg.exec()
|
||||
@@ -1,405 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access.
|
||||
|
||||
Features:
|
||||
- Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`.
|
||||
- Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`.
|
||||
- Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut.
|
||||
- Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent, *args, **kwargs)
|
||||
|
||||
self._widgets_by_name: Dict[str, QWidget] = {}
|
||||
self._init_ui()
|
||||
|
||||
# expose helper API and basics in the inprocess console
|
||||
if self.console.inprocess is True:
|
||||
# A thin API wrapper so users have a stable, minimal surface in the console
|
||||
class _ConsoleAPI:
|
||||
def __init__(self, win: "JupyterConsoleWindow"):
|
||||
self._win = win
|
||||
|
||||
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None):
|
||||
"""Add an existing QWidget as a new tab and expose it in the console under `shortcut`."""
|
||||
return self._win.add_widget(widget, shortcut, title=title)
|
||||
|
||||
def add_widget_by_class_path(
|
||||
self,
|
||||
class_path: str,
|
||||
shortcut: str,
|
||||
kwargs: dict | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
"""Import a QWidget class from `class_path`, instantiate it, and add it."""
|
||||
return self._win.add_widget_by_class_path(
|
||||
class_path, shortcut, kwargs=kwargs, title=title
|
||||
)
|
||||
|
||||
def add_widget_by_type(
|
||||
self,
|
||||
widget_type: str,
|
||||
shortcut: str,
|
||||
kwargs: dict | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
"""Instantiate a registered BEC widget by type string and add it."""
|
||||
return self._win.add_widget_by_type(
|
||||
widget_type, shortcut, kwargs=kwargs, title=title
|
||||
)
|
||||
|
||||
def list_widgets(self):
|
||||
return list(self._win._widgets_by_name.keys())
|
||||
|
||||
def get_widget(self, shortcut: str) -> QWidget | None:
|
||||
return self._win._widgets_by_name.get(shortcut)
|
||||
|
||||
def available_widgets(self):
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
self.jc = _ConsoleAPI(self)
|
||||
self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh})
|
||||
|
||||
def _init_ui(self):
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
# Horizontal splitter: left = widgets tabs, right = console + add-widget panel
|
||||
splitter = QSplitter(self)
|
||||
self.layout.addWidget(splitter)
|
||||
|
||||
# Left: tabs that will host dynamically added widgets
|
||||
self.tab_widget = QTabWidget(splitter)
|
||||
|
||||
# Right: console area with an add-widget mini panel on top
|
||||
right_panel = QGroupBox("Jupyter Console", splitter)
|
||||
right_layout = QVBoxLayout(right_panel)
|
||||
right_layout.setContentsMargins(6, 12, 6, 6)
|
||||
|
||||
# Add-widget mini panel
|
||||
add_panel = QFrame(right_panel)
|
||||
shape = QFrame.Shape.StyledPanel # PySide6 style enums
|
||||
add_panel.setFrameShape(shape)
|
||||
add_grid = QGridLayout(add_panel)
|
||||
add_grid.setContentsMargins(8, 8, 8, 8)
|
||||
add_grid.setHorizontalSpacing(8)
|
||||
add_grid.setVerticalSpacing(6)
|
||||
|
||||
instr = QLabel(
|
||||
"Add a widget by class path or choose a registered BEC widget type,"
|
||||
" and expose it in the console under a shortcut.\n"
|
||||
"Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform"
|
||||
)
|
||||
instr.setWordWrap(True)
|
||||
add_grid.addWidget(instr, 0, 0, 1, 2)
|
||||
|
||||
# Registered widget selector
|
||||
reg_label = QLabel("Registered")
|
||||
reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.registry_combo = QComboBox(add_panel)
|
||||
self.registry_combo.setEditable(False)
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
reg_row = QHBoxLayout()
|
||||
reg_row.addWidget(self.registry_combo)
|
||||
reg_row.addWidget(self.refresh_btn)
|
||||
add_grid.addWidget(reg_label, 1, 0)
|
||||
add_grid.addLayout(reg_row, 1, 1)
|
||||
|
||||
# Class path entry
|
||||
class_label = QLabel("Class")
|
||||
class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.class_path_edit = QLineEdit(add_panel)
|
||||
self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)")
|
||||
add_grid.addWidget(class_label, 2, 0)
|
||||
add_grid.addWidget(self.class_path_edit, 2, 1)
|
||||
|
||||
# Shortcut
|
||||
shortcut_label = QLabel("Shortcut")
|
||||
shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.shortcut_edit = QLineEdit(add_panel)
|
||||
self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)")
|
||||
add_grid.addWidget(shortcut_label, 3, 0)
|
||||
add_grid.addWidget(self.shortcut_edit, 3, 1)
|
||||
|
||||
# Kwargs
|
||||
kwargs_label = QLabel("Kwargs")
|
||||
kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.kwargs_edit = QLineEdit(add_panel)
|
||||
self.kwargs_edit.setPlaceholderText(
|
||||
'Optional kwargs as dict literal, e.g. {"popups": True}'
|
||||
)
|
||||
add_grid.addWidget(kwargs_label, 4, 0)
|
||||
add_grid.addWidget(self.kwargs_edit, 4, 1)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("Title")
|
||||
title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.title_edit = QLineEdit(add_panel)
|
||||
self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)")
|
||||
add_grid.addWidget(title_label, 5, 0)
|
||||
add_grid.addWidget(self.title_edit, 5, 1)
|
||||
|
||||
# Buttons
|
||||
btn_row = QHBoxLayout()
|
||||
self.add_btn = QPushButton("Add by class path")
|
||||
self.add_btn.clicked.connect(self._on_add_widget_clicked)
|
||||
self.add_reg_btn = QPushButton("Add registered")
|
||||
self.add_reg_btn.clicked.connect(self._on_add_registered_clicked)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(self.add_reg_btn)
|
||||
btn_row.addWidget(self.add_btn)
|
||||
add_grid.addLayout(btn_row, 6, 0, 1, 2)
|
||||
|
||||
# Make the second column expand
|
||||
add_grid.setColumnStretch(0, 0)
|
||||
add_grid.setColumnStretch(1, 1)
|
||||
|
||||
# Console widget
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
|
||||
# Vertical splitter between add panel and console
|
||||
right_splitter = QSplitter(Qt.Vertical, right_panel)
|
||||
right_splitter.addWidget(add_panel)
|
||||
right_splitter.addWidget(self.console)
|
||||
right_splitter.setStretchFactor(0, 0)
|
||||
right_splitter.setStretchFactor(1, 1)
|
||||
right_splitter.setSizes([300, 600])
|
||||
|
||||
# Put splitter into the right group box
|
||||
right_layout.addWidget(right_splitter)
|
||||
|
||||
# Populate registry on startup
|
||||
self._populate_registry_widgets()
|
||||
|
||||
def _populate_registry_widgets(self):
|
||||
try:
|
||||
widget_handler.update_available_widgets()
|
||||
items = sorted(widget_handler.widget_classes.keys())
|
||||
except Exception as exc:
|
||||
print(f"Failed to load registered widgets: {exc}")
|
||||
items = []
|
||||
self.registry_combo.clear()
|
||||
self.registry_combo.addItems(items)
|
||||
|
||||
def _on_add_widget_clicked(self):
|
||||
class_path = self.class_path_edit.text().strip()
|
||||
shortcut = self.shortcut_edit.text().strip()
|
||||
kwargs_text = self.kwargs_edit.text().strip()
|
||||
title = self.title_edit.text().strip() or None
|
||||
|
||||
if not class_path or not shortcut:
|
||||
print("Please provide both class path and shortcut.")
|
||||
return
|
||||
|
||||
kwargs: dict | None = None
|
||||
if kwargs_text:
|
||||
try:
|
||||
parsed = ast.literal_eval(kwargs_text)
|
||||
if isinstance(parsed, dict):
|
||||
kwargs = parsed
|
||||
else:
|
||||
print("Kwargs must be a Python dict literal, ignoring input.")
|
||||
except Exception as exc:
|
||||
print(f"Failed to parse kwargs: {exc}")
|
||||
|
||||
try:
|
||||
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
|
||||
except Exception as exc:
|
||||
print(f"Failed to instantiate {class_path}: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
self.add_widget(widget, shortcut, title=title)
|
||||
except Exception as exc:
|
||||
print(f"Failed to add widget: {exc}")
|
||||
return
|
||||
|
||||
# focus the newly added tab
|
||||
idx = self.tab_widget.count() - 1
|
||||
if idx >= 0:
|
||||
self.tab_widget.setCurrentIndex(idx)
|
||||
|
||||
def _on_add_registered_clicked(self):
|
||||
widget_type = self.registry_combo.currentText().strip()
|
||||
shortcut = self.shortcut_edit.text().strip()
|
||||
kwargs_text = self.kwargs_edit.text().strip()
|
||||
title = self.title_edit.text().strip() or None
|
||||
|
||||
if not widget_type or not shortcut:
|
||||
print("Please select a registered widget and provide a shortcut.")
|
||||
return
|
||||
|
||||
kwargs: dict | None = None
|
||||
if kwargs_text:
|
||||
try:
|
||||
parsed = ast.literal_eval(kwargs_text)
|
||||
if isinstance(parsed, dict):
|
||||
kwargs = parsed
|
||||
else:
|
||||
print("Kwargs must be a Python dict literal, ignoring input.")
|
||||
except Exception as exc:
|
||||
print(f"Failed to parse kwargs: {exc}")
|
||||
|
||||
try:
|
||||
self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title)
|
||||
except Exception as exc:
|
||||
print(f"Failed to add registered widget: {exc}")
|
||||
return
|
||||
|
||||
# focus the newly added tab
|
||||
idx = self.tab_widget.count() - 1
|
||||
if idx >= 0:
|
||||
self.tab_widget.setCurrentIndex(idx)
|
||||
|
||||
def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget:
|
||||
module_path, _, class_name = class_path.rpartition(".")
|
||||
if not module_path or not class_name:
|
||||
raise ValueError("class_path must be of the form 'package.module.Class'")
|
||||
module = importlib.import_module(module_path)
|
||||
cls = getattr(module, class_name)
|
||||
if kwargs is None:
|
||||
obj = cls()
|
||||
else:
|
||||
obj = cls(**kwargs)
|
||||
if not isinstance(obj, QWidget):
|
||||
raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}")
|
||||
return obj
|
||||
|
||||
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget:
|
||||
"""Add a QWidget as a new tab and expose it in the Jupyter console.
|
||||
|
||||
- widget: a QWidget instance to host in a new tab
|
||||
- shortcut: variable name used in the console to access it
|
||||
- title: optional tab title (defaults to shortcut or class name)
|
||||
"""
|
||||
if not isinstance(widget, QWidget):
|
||||
raise TypeError("widget must be a QWidget instance")
|
||||
if not shortcut or not shortcut.isidentifier():
|
||||
raise ValueError("shortcut must be a valid Python identifier")
|
||||
if shortcut in self._widgets_by_name:
|
||||
raise ValueError(f"A widget with shortcut '{shortcut}' already exists")
|
||||
if self.console.inprocess is not True:
|
||||
raise RuntimeError("Adding widgets and exposing them requires inprocess console")
|
||||
|
||||
tab_title = title or shortcut or widget.__class__.__name__
|
||||
self.tab_widget.addTab(widget, tab_title)
|
||||
self._widgets_by_name[shortcut] = widget
|
||||
|
||||
# Expose in console under the given shortcut
|
||||
self._push_to_console({shortcut: widget})
|
||||
return widget
|
||||
|
||||
def add_widget_by_class_path(
|
||||
self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
|
||||
) -> QWidget:
|
||||
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
|
||||
return self.add_widget(widget, shortcut, title=title)
|
||||
|
||||
def add_widget_by_type(
|
||||
self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
|
||||
) -> QWidget:
|
||||
"""Instantiate a registered BEC widget by its type string and add it as a tab.
|
||||
|
||||
If kwargs does not contain `object_name`, it will default to the provided shortcut.
|
||||
"""
|
||||
# Ensure registry is loaded
|
||||
widget_handler.update_available_widgets()
|
||||
cls = widget_handler.widget_classes.get(widget_type)
|
||||
if cls is None:
|
||||
raise ValueError(f"Unknown registered widget type: {widget_type}")
|
||||
|
||||
if kwargs is None:
|
||||
kwargs = {"object_name": shortcut}
|
||||
else:
|
||||
kwargs = dict(kwargs)
|
||||
kwargs.setdefault("object_name", shortcut)
|
||||
|
||||
# Instantiate and add
|
||||
widget = cls(**kwargs)
|
||||
if not isinstance(widget, QWidget):
|
||||
raise TypeError(
|
||||
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
|
||||
)
|
||||
return self.add_widget(widget, shortcut, title=title)
|
||||
|
||||
def _push_to_console(self, mapping: Dict[str, Any]):
|
||||
"""Push Python objects into the inprocess kernel user namespace."""
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(mapping)
|
||||
else:
|
||||
raise RuntimeError("Can only push variables when using inprocess kernel")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
|
||||
# Ensure the embedded kernel and BEC client are shut down before window teardown
|
||||
self.console.shutdown_kernel()
|
||||
self.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
import bec_widgets
|
||||
|
||||
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)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
|
||||
# Examples: add two widgets programmatically to demonstrate usage
|
||||
try:
|
||||
win.add_widget_by_type("Waveform", shortcut="wf")
|
||||
except Exception as exc:
|
||||
print(f"Example add failed (Waveform by type): {exc}")
|
||||
|
||||
try:
|
||||
win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True})
|
||||
except Exception as exc:
|
||||
print(f"Example add failed (Image by type): {exc}")
|
||||
|
||||
win.show()
|
||||
win.resize(1500, 800)
|
||||
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,18 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
|
||||
|
||||
import sys
|
||||
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = TicTacToe()
|
||||
window.state = "-X-XO----"
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoeplugin import TicTacToePlugin
|
||||
|
||||
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
|
||||
"tictactoetaskmenu.py"]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
EMPTY = "-"
|
||||
CROSS = "X"
|
||||
NOUGHT = "O"
|
||||
DEFAULT_STATE = "---------"
|
||||
|
||||
|
||||
class TicTacToe(QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def setState(self, new_state):
|
||||
self._turn_number = 0
|
||||
self._state = DEFAULT_STATE
|
||||
for position in range(min(9, len(new_state))):
|
||||
mark = new_state[position]
|
||||
if mark == CROSS or mark == NOUGHT:
|
||||
self._turn_number += 1
|
||||
self._change_state_at(position, mark)
|
||||
position += 1
|
||||
self.update()
|
||||
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@Slot()
|
||||
def clear_board(self):
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
self.update()
|
||||
|
||||
def _change_state_at(self, pos, new_state):
|
||||
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self._turn_number == 9:
|
||||
self.clear_board()
|
||||
return
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if cell.contains(event.position().toPoint()):
|
||||
if self._state[position] == EMPTY:
|
||||
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
|
||||
self._change_state_at(position, new_state)
|
||||
self._turn_number += 1
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
with QPainter(self) as painter:
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
painter.setPen(QPen(Qt.darkGreen, 1))
|
||||
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
|
||||
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
|
||||
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
|
||||
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
|
||||
|
||||
painter.setPen(QPen(Qt.darkBlue, 2))
|
||||
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if self._state[position] == CROSS:
|
||||
painter.drawLine(cell.topLeft(), cell.bottomRight())
|
||||
painter.drawLine(cell.topRight(), cell.bottomLeft())
|
||||
elif self._state[position] == NOUGHT:
|
||||
painter.drawEllipse(cell)
|
||||
|
||||
painter.setPen(QPen(Qt.yellow, 3))
|
||||
|
||||
for position in range(0, 8, 3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 1] == self._state[position]
|
||||
and self._state[position + 2] == self._state[position]
|
||||
):
|
||||
y = self._cell_rect(position).center().y()
|
||||
painter.drawLine(0, y, self.width(), y)
|
||||
self._turn_number = 9
|
||||
|
||||
for position in range(3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 3] == self._state[position]
|
||||
and self._state[position + 6] == self._state[position]
|
||||
):
|
||||
x = self._cell_rect(position).center().x()
|
||||
painter.drawLine(x, 0, x, self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[0] != EMPTY
|
||||
and self._state[4] == self._state[0]
|
||||
and self._state[8] == self._state[0]
|
||||
):
|
||||
painter.drawLine(0, 0, self.width(), self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[2] != EMPTY
|
||||
and self._state[4] == self._state[2]
|
||||
and self._state[6] == self._state[2]
|
||||
):
|
||||
painter.drawLine(0, self.height(), self.width(), 0)
|
||||
self._turn_number = 9
|
||||
|
||||
def _cell_rect(self, position):
|
||||
h_margin = self.width() / 30
|
||||
v_margin = self.height() / 30
|
||||
row = int(position / 3)
|
||||
column = position - 3 * row
|
||||
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
|
||||
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
|
||||
return QRect(pos, size)
|
||||
|
||||
def _cell_width(self):
|
||||
return self.width() / 3
|
||||
|
||||
def _cell_height(self):
|
||||
return self.height() / 3
|
||||
|
||||
state = Property(str, state, setState)
|
||||
@@ -1,73 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu import TicTacToeTaskMenuFactory
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='TicTacToe' name='ticTacToe'>
|
||||
<property name='geometry'>
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>200</width>
|
||||
<height>200</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name='state'>
|
||||
<string>-X-XO----</string>
|
||||
</property>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = TicTacToe(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "Games"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon("sports_esports")
|
||||
|
||||
def includeFile(self):
|
||||
return "tictactoe"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
manager = form_editor.extensionManager()
|
||||
iid = TicTacToeTaskMenuFactory.task_menu_iid()
|
||||
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "TicTacToe"
|
||||
|
||||
def toolTip(self):
|
||||
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -1,68 +0,0 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class TicTacToeDialog(QDialog): # pragma: no cover
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self._ticTacToe = TicTacToe(self)
|
||||
layout.addWidget(self._ticTacToe)
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
|
||||
)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
reset_button = button_box.button(QDialogButtonBox.Reset)
|
||||
reset_button.clicked.connect(self._ticTacToe.clear_board)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def set_state(self, new_state):
|
||||
self._ticTacToe.setState(new_state)
|
||||
|
||||
def state(self):
|
||||
return self._ticTacToe.state
|
||||
|
||||
|
||||
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
|
||||
def __init__(self, ticTacToe, parent):
|
||||
super().__init__(parent)
|
||||
self._ticTacToe = ticTacToe
|
||||
self._edit_state_action = QAction("Edit State...", None)
|
||||
self._edit_state_action.triggered.connect(self._edit_state)
|
||||
|
||||
def taskActions(self):
|
||||
return [self._edit_state_action]
|
||||
|
||||
def preferredEditAction(self):
|
||||
return self._edit_state_action
|
||||
|
||||
@Slot()
|
||||
def _edit_state(self):
|
||||
dialog = TicTacToeDialog(self._ticTacToe)
|
||||
dialog.set_state(self._ticTacToe.state)
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
self._ticTacToe.state = dialog.state()
|
||||
|
||||
|
||||
class TicTacToeTaskMenuFactory(QExtensionFactory):
|
||||
def __init__(self, extension_manager):
|
||||
super().__init__(extension_manager)
|
||||
|
||||
@staticmethod
|
||||
def task_menu_iid():
|
||||
return "org.qt-project.Qt.Designer.TaskMenu"
|
||||
|
||||
def createExtension(self, object, iid, parent):
|
||||
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
|
||||
return None
|
||||
if object.__class__.__name__ != "TicTacToe":
|
||||
return None
|
||||
return TicTacToeTaskMenu(object, parent)
|
||||
4
bec_widgets/readme.md
Normal file
4
bec_widgets/readme.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Add/modify the path in the following variable to make the plugin avaiable in Qt Designer:
|
||||
```
|
||||
$ export PYQTDESIGNERPATH=/<path to repo>/bec/bec_qtplugin:$PYQTDESIGNERPATH
|
||||
```
|
||||
106
bec_widgets/scan_plot.py
Normal file
106
bec_widgets/scan_plot.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import itertools
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
|
||||
from bec_lib.core.logger import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"]
|
||||
|
||||
|
||||
class BECScanPlot(pg.PlotWidget):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
|
||||
self._x_channel = ""
|
||||
self._y_channel_list = []
|
||||
|
||||
self.scan_curves = {}
|
||||
self.dap_curves = {}
|
||||
|
||||
def initialize(self):
|
||||
plot_item = self.getPlotItem()
|
||||
plot_item.addLegend()
|
||||
colors = itertools.cycle(COLORS)
|
||||
|
||||
for y_chan in self.y_channel_list:
|
||||
if y_chan.startswith("dap."):
|
||||
y_chan = y_chan.partition("dap.")[-1]
|
||||
curves = self.dap_curves
|
||||
else:
|
||||
curves = self.scan_curves
|
||||
|
||||
curves[y_chan] = plot_item.plot(
|
||||
x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan
|
||||
)
|
||||
|
||||
plot_item.setLabel("bottom", self._x_channel)
|
||||
if len(self.scan_curves) == 1:
|
||||
plot_item.setLabel("left", next(iter(self.scan_curves)))
|
||||
|
||||
@pyqtSlot()
|
||||
def clearData(self):
|
||||
for plot_curve in {**self.scan_curves, **self.dap_curves}.values():
|
||||
plot_curve.setData(x=[], y=[])
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def redraw_scan(self, data):
|
||||
if not self.x_channel:
|
||||
return
|
||||
|
||||
if self.x_channel not in data:
|
||||
logger.warning(f"Unknown channel `{self.x_channel}` for X data in {self.objectName()}")
|
||||
return
|
||||
|
||||
x_new = data[self.x_channel][self.x_channel]["value"]
|
||||
for chan, plot_curve in self.scan_curves.items():
|
||||
if not chan:
|
||||
continue
|
||||
|
||||
if chan not in data:
|
||||
logger.warning(f"Unknown channel `{chan}` for Y data in {self.objectName()}")
|
||||
continue
|
||||
|
||||
y_new = data[chan][chan]["value"]
|
||||
x, y = plot_curve.getData() # TODO: is it a good approach?
|
||||
if x is None:
|
||||
x = []
|
||||
if y is None:
|
||||
y = []
|
||||
|
||||
plot_curve.setData(x=[*x, x_new], y=[*y, y_new])
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def redraw_dap(self, data):
|
||||
for chan, plot_curve in self.dap_curves.items():
|
||||
if not chan:
|
||||
continue
|
||||
|
||||
if chan not in data:
|
||||
logger.warning(f"Unknown channel `{chan}` for DAP data in {self.objectName()}")
|
||||
continue
|
||||
|
||||
x_new = data[chan]["x"]
|
||||
y_new = data[chan]["y"]
|
||||
|
||||
plot_curve.setData(x=x_new, y=y_new)
|
||||
|
||||
@pyqtProperty("QStringList")
|
||||
def y_channel_list(self):
|
||||
return self._y_channel_list
|
||||
|
||||
@y_channel_list.setter
|
||||
def y_channel_list(self, new_list):
|
||||
self._y_channel_list = new_list
|
||||
|
||||
@pyqtProperty(str)
|
||||
def x_channel(self):
|
||||
return self._x_channel
|
||||
|
||||
@x_channel.setter
|
||||
def x_channel(self, new_val):
|
||||
self._x_channel = new_val
|
||||
55
bec_widgets/scan_plot_plugin.py
Normal file
55
bec_widgets/scan_plot_plugin.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
from scan_plot import BECScanPlot
|
||||
|
||||
|
||||
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super(BECScanPlotPlugin, self).__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot" name="BECScanPlot">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_plot"
|
||||
@@ -1,303 +0,0 @@
|
||||
# pylint: skip-file
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_lib.device import Device as BECDevice
|
||||
from bec_lib.device import Positioner as BECPositioner
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
|
||||
|
||||
class FakeDevice(BECDevice):
|
||||
"""Fake minimal positioner class for testing."""
|
||||
|
||||
def __init__(self, name, enabled=True, readout_priority=ReadoutPriority.MONITORED):
|
||||
super().__init__(name=name)
|
||||
self._enabled = enabled
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._readout_priority = readout_priority
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": {"user device"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
self.name: {
|
||||
"kind_str": "hinted",
|
||||
"component_name": self.name,
|
||||
"obj_name": self.name,
|
||||
"signal_class": "Signal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.signals[self.name]["value"] = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
|
||||
class FakePositioner(BECPositioner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
enabled=True,
|
||||
limits=None,
|
||||
read_value=1.0,
|
||||
readout_priority=ReadoutPriority.MONITORED,
|
||||
):
|
||||
super().__init__(name=name)
|
||||
# self.limits = limits if limits is not None else [0.0, 0.0]
|
||||
self.read_value = read_value
|
||||
self.setpoint_value = read_value
|
||||
self.motor_is_moving_value = 0
|
||||
self._enabled = enabled
|
||||
self._limits = limits
|
||||
self._readout_priority = readout_priority
|
||||
self.signals = {self.name: {"value": 1.0}}
|
||||
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
|
||||
self._config = {
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": {"user motors"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"obj_name": self.name,
|
||||
}, # hinted
|
||||
"setpoint": {
|
||||
"kind_str": "normal",
|
||||
"component_name": "setpoint",
|
||||
"obj_name": f"{self.name}_setpoint",
|
||||
}, # normal
|
||||
"velocity": {
|
||||
"kind_str": "config",
|
||||
"component_name": "velocity",
|
||||
"obj_name": f"{self.name}_velocity",
|
||||
}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
self.name: {"value": self.read_value},
|
||||
f"{self.name}_setpoint": {"value": self.setpoint_value},
|
||||
f"{self.name}_motor_is_moving": {"value": self.motor_is_moving_value},
|
||||
}
|
||||
|
||||
@property
|
||||
def readout_priority(self):
|
||||
return self._readout_priority
|
||||
|
||||
@readout_priority.setter
|
||||
def readout_priority(self, value):
|
||||
self._readout_priority = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@limits.setter
|
||||
def limits(self, value: tuple[float, float]):
|
||||
self._limits = value
|
||||
|
||||
def __contains__(self, item):
|
||||
return item == self.name
|
||||
|
||||
@property
|
||||
def _hints(self):
|
||||
return [self.name]
|
||||
|
||||
def set_value(self, fake_value: float = 1.0) -> None:
|
||||
"""
|
||||
Setup fake value for device readout
|
||||
Args:
|
||||
fake_value(float): Desired fake value
|
||||
"""
|
||||
self.read_value = fake_value
|
||||
|
||||
def describe(self) -> dict:
|
||||
"""
|
||||
Get the description of the device
|
||||
Returns:
|
||||
dict: Description of the device
|
||||
"""
|
||||
return self.description
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
return 3
|
||||
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self, cached=False):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
self.limits = limits
|
||||
|
||||
def move(self, value, relative=False):
|
||||
"""Simulates moving the device to a new position."""
|
||||
if relative:
|
||||
self.read_value += value
|
||||
else:
|
||||
self.read_value = value
|
||||
# Respect the limits
|
||||
self.read_value = max(min(self.read_value, self.limits[1]), self.limits[0])
|
||||
|
||||
@property
|
||||
def readback(self):
|
||||
return MagicMock(get=MagicMock(return_value=self.read_value))
|
||||
|
||||
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name, enabled=True):
|
||||
super().__init__(name, enabled)
|
||||
|
||||
|
||||
class DMMock:
|
||||
def __init__(self):
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
def _get_redis_device_config(self) -> list[dict]:
|
||||
"""Mock method to emulate DeviceManager._get_redis_device_config."""
|
||||
configs = []
|
||||
for device in self.devices.values():
|
||||
configs.append(device._config)
|
||||
return configs
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
FakePositioner("samy", limits=[-5, 5], read_value=3.0),
|
||||
FakePositioner("samz", limits=[-8, 8], read_value=4.0),
|
||||
FakePositioner("aptrx", limits=None, read_value=4.0),
|
||||
FakePositioner("aptry", limits=None, read_value=5.0),
|
||||
FakeDevice("gauss_bpm"),
|
||||
FakeDevice("gauss_adc1"),
|
||||
FakeDevice("gauss_adc2"),
|
||||
FakeDevice("gauss_adc3"),
|
||||
FakeDevice("bpm4i"),
|
||||
FakeDevice("bpm3a"),
|
||||
FakeDevice("bpm3i"),
|
||||
FakeDevice("eiger", readout_priority=ReadoutPriority.ASYNC),
|
||||
FakeDevice("waveform1d"),
|
||||
FakeDevice("async_device", readout_priority=ReadoutPriority.ASYNC),
|
||||
Positioner("test", limits=[-10, 10], read_value=2.0),
|
||||
Device("test_device"),
|
||||
]
|
||||
|
||||
|
||||
def check_remote_data_size(widget, plot_name, num_elements):
|
||||
"""
|
||||
Check if the remote data has the correct number of elements.
|
||||
Used in the qtbot.waitUntil function.
|
||||
"""
|
||||
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
||||
@@ -1,13 +0,0 @@
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .bec_connector import BECConnector, ConnectionConfig
|
||||
from .bec_dispatcher import BECDispatcher
|
||||
from .bec_table import BECTable
|
||||
from .colors import Colors
|
||||
from .container_utils import WidgetContainerUtils
|
||||
from .crosshair import Crosshair
|
||||
from .entry_validator import EntryValidator
|
||||
from .layout_manager import GridLayoutManager
|
||||
from .rpc_decorator import register_rpc_methods, rpc_public
|
||||
from .ui_loader import UILoader
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
@@ -1,617 +0,0 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import shiboken6 as shb
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
else:
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
"""Configuration for BECConnector mixin class"""
|
||||
|
||||
widget_class: str = Field(default="NonSpecifiedWidget", description="The class of the widget.")
|
||||
gui_id: Optional[str] = Field(
|
||||
default=None, validate_default=True, description="The GUI ID of the widget."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("gui_id")
|
||||
@classmethod
|
||||
def generate_gui_id(cls, v, values):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
|
||||
return v
|
||||
|
||||
|
||||
class WorkerSignals(QObject):
|
||||
progress = Signal(dict)
|
||||
completed = Signal()
|
||||
|
||||
|
||||
class Worker(QRunnable):
|
||||
"""
|
||||
Worker class to run a function in a separate thread.
|
||||
"""
|
||||
|
||||
def __init__(self, func, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.signals = WorkerSignals()
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the specified function in the thread.
|
||||
"""
|
||||
self.func(*self.args, **self.kwargs)
|
||||
self.signals.completed.emit()
|
||||
|
||||
|
||||
class BECConnector:
|
||||
"""Connection mixin class to handle BEC client and device manager"""
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
widget_removed = Signal()
|
||||
name_established = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
object_name: str | None = None,
|
||||
root_widget: bool = False,
|
||||
rpc_exposed: bool = True,
|
||||
rpc_passthrough_children: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
BECConnector mixin class to handle BEC client and device manager.
|
||||
|
||||
Args:
|
||||
client(BECClient, optional): The BEC client.
|
||||
config(ConnectionConfig, optional): The connection configuration with specific gui id.
|
||||
gui_id(str, optional): The GUI ID.
|
||||
object_name(str, optional): The object name.
|
||||
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
rpc_exposed(bool, optional): If set to False, this instance is excluded from RPC registry broadcast and CLI namespace discovery.
|
||||
rpc_passthrough_children(bool, optional): Only relevant when ``rpc_exposed=False``.
|
||||
If True, RPC-visible children rebind to the next visible ancestor.
|
||||
If False (default), children stay hidden behind this widget.
|
||||
**kwargs:
|
||||
"""
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
object_name = object_name or kwargs.pop("objectName", None)
|
||||
if object_name is not None:
|
||||
object_name = sanitize_namespace(object_name)
|
||||
# Ensure the parent is always the first argument for QObject
|
||||
parent = kwargs.pop("parent", None)
|
||||
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
assert isinstance(
|
||||
self, QObject
|
||||
), "BECConnector must be used with a QObject or any qt related class."
|
||||
|
||||
# flag to check if the object was destroyed and its cleanup was called
|
||||
self._destroyed = False
|
||||
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
self.rpc_register = RPCRegister()
|
||||
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@SafeSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
app = QApplication.instance()
|
||||
gui_server = getattr(app, "gui_server", None)
|
||||
if gui_server and hasattr(gui_server, "shutdown"):
|
||||
gui_server.shutdown()
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
dispatcher.stop_cli_server()
|
||||
|
||||
try: # shutdown ophyd threads if any
|
||||
from ophyd._pyepics_shim import _dispatcher
|
||||
|
||||
_dispatcher.stop()
|
||||
logger.info("Ophyd dispatcher shut down successfully.")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
logger.info("Shutting down BEC Client", repr(client))
|
||||
client.shutdown()
|
||||
|
||||
BECConnector.EXIT_HANDLERS[self.client] = terminate
|
||||
QApplication.instance().aboutToQuit.connect(terminate)
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
self.config.widget_class = self.__class__.__name__
|
||||
else:
|
||||
logger.debug(
|
||||
f"No initial config found for {self.__class__.__name__}.\n"
|
||||
f"Initializing with default config."
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||
# the gui_id has to be unique, and may no longer be.
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id: str = gui_id # Keep namespace in sync
|
||||
else:
|
||||
self.gui_id: str = self.config.gui_id # type: ignore
|
||||
|
||||
if object_name is not None:
|
||||
self.setObjectName(object_name)
|
||||
|
||||
# 1) If no objectName is set, set the initial name
|
||||
if not self.objectName():
|
||||
self.setObjectName(self.__class__.__name__)
|
||||
self.object_name = self.objectName()
|
||||
|
||||
# 2) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self.setParent(parent)
|
||||
|
||||
# Error popups
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
self.root_widget = root_widget
|
||||
# If set to False, this instance is not exposed through RPC at all.
|
||||
self.rpc_exposed = bool(rpc_exposed)
|
||||
# If True on a hidden parent (rpc_exposed=False), children can bubble up to
|
||||
# the next visible RPC ancestor.
|
||||
self.rpc_passthrough_children = bool(rpc_passthrough_children)
|
||||
|
||||
self._update_object_name()
|
||||
|
||||
@property
|
||||
def parent_id(self) -> str | None:
|
||||
try:
|
||||
if self.root_widget:
|
||||
return None
|
||||
connector_parent = self._get_rpc_parent_ancestor()
|
||||
return connector_parent.gui_id if connector_parent else None
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
def _get_rpc_parent_ancestor(self) -> BECConnector | None:
|
||||
"""
|
||||
Find the nearest ancestor that is RPC-addressable.
|
||||
|
||||
Rules:
|
||||
- If an ancestor has ``rpc_exposed=False``, it is an explicit visibility
|
||||
boundary unless ``rpc_passthrough_children=True``.
|
||||
- If an ancestor has ``RPC=False`` (but remains rpc_exposed), it is treated
|
||||
as structural and children continue to the next ancestor.
|
||||
- Lookup always happens through ``WidgetHierarchy.get_becwidget_ancestor``
|
||||
so plain ``QWidget`` nodes between connectors are ignored.
|
||||
"""
|
||||
current = self
|
||||
while True:
|
||||
parent = WidgetHierarchy.get_becwidget_ancestor(current)
|
||||
if parent is None:
|
||||
return None
|
||||
|
||||
if not getattr(parent, "rpc_exposed", True):
|
||||
if getattr(parent, "rpc_passthrough_children", False):
|
||||
current = parent
|
||||
continue
|
||||
return parent
|
||||
|
||||
if getattr(parent, "RPC", True):
|
||||
return parent
|
||||
|
||||
current = parent
|
||||
return None
|
||||
|
||||
def change_object_name(self, name: str) -> None:
|
||||
"""
|
||||
Change the object name of the widget. Unregister old name and register the new one.
|
||||
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
|
||||
self._update_object_name()
|
||||
|
||||
def _update_object_name(self) -> None:
|
||||
"""
|
||||
Enforce a unique object name among siblings and register the object for RPC.
|
||||
This method is called through a single shot timer kicked off in the constructor.
|
||||
"""
|
||||
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC unless instance-level exposure is disabled.
|
||||
if getattr(self, "rpc_exposed", True):
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error emitting name_established signal: {e}")
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
Enforce that this BECConnector has a unique objectName among its siblings.
|
||||
|
||||
Sibling logic:
|
||||
- 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.
|
||||
"""
|
||||
if not shb.isValid(self):
|
||||
return
|
||||
|
||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
# We have a parent => only compare with siblings under that parent
|
||||
siblings = [sib for sib in parent_bec.findChildren(BECConnector) if shb.isValid(sib)]
|
||||
else:
|
||||
# No parent => treat all top-level BECConnectors as siblings
|
||||
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
|
||||
connections = self.rpc_register.list_all_connections().values()
|
||||
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
|
||||
siblings = [w for w in all_bec if WidgetHierarchy.get_becwidget_ancestor(w) is None]
|
||||
|
||||
# Collect used names among siblings
|
||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||
|
||||
base_name = self.object_name
|
||||
if base_name not in used_names:
|
||||
# Name is already unique among siblings
|
||||
return
|
||||
|
||||
# Need a suffix to avoid collision
|
||||
counter = 0
|
||||
while True:
|
||||
trial_name = f"{base_name}_{counter}"
|
||||
if trial_name not in used_names:
|
||||
self.setObjectName(trial_name)
|
||||
self.object_name = trial_name
|
||||
break
|
||||
counter += 1
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def setObjectName(self, name: str) -> None:
|
||||
"""
|
||||
Set the object name of the widget.
|
||||
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
# sanitize before setting to avoid issues with Qt object names and RPC namespaces
|
||||
name = sanitize_namespace(name)
|
||||
super().setObjectName(name)
|
||||
self.object_name = name
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
self.rpc_register.broadcast()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
|
||||
Use this method if you want to wait for a task to complete without blocking the
|
||||
main thread.
|
||||
|
||||
Args:
|
||||
fn: Function to run in a separate thread.
|
||||
*args: Arguments for the function.
|
||||
on_complete: Slot to run when the task is complete.
|
||||
**kwargs: Keyword arguments for the function.
|
||||
|
||||
Returns:
|
||||
worker: The worker object that will run the task.
|
||||
|
||||
Examples:
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> self.submit_task(my_function, 1, 2)
|
||||
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> def on_complete():
|
||||
>>> print("Task complete")
|
||||
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
|
||||
"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
if on_complete:
|
||||
worker.signals.completed.connect(on_complete)
|
||||
# Keep a reference to the worker so it is not garbage collected.
|
||||
self._workers.append(worker)
|
||||
# When the worker is done, remove it from our list.
|
||||
worker.signals.completed.connect(lambda: self._workers.remove(worker))
|
||||
self._thread_pool.start(worker)
|
||||
return worker
|
||||
|
||||
def _get_all_rpc(self) -> dict:
|
||||
"""Get all registered RPC objects."""
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
return dict(all_connections)
|
||||
|
||||
@property
|
||||
def _rpc_id(self) -> str:
|
||||
"""Get the RPC ID of the widget."""
|
||||
return self.gui_id
|
||||
|
||||
@_rpc_id.setter
|
||||
def _rpc_id(self, rpc_id: str) -> None:
|
||||
"""Set the RPC ID of the widget."""
|
||||
self.gui_id = rpc_id
|
||||
|
||||
@property
|
||||
def _config_dict(self) -> dict:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
return self.config.model_dump()
|
||||
|
||||
@_config_dict.setter
|
||||
def _config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Set the configuration of the widget.
|
||||
|
||||
Args:
|
||||
config (BaseModel): The new configuration model.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration settings.
|
||||
generate_new_id (bool): If True, generate a new GUI ID for the widget.
|
||||
"""
|
||||
self.config = ConnectionConfig(**config)
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self._set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path (str | None): Path to the configuration file for non-GUI dialog mode.
|
||||
gui (bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
config = load_yaml_gui(self)
|
||||
else:
|
||||
config = load_yaml(path)
|
||||
|
||||
if config is not None:
|
||||
if config.get("widget_class") != self.__class__.__name__:
|
||||
raise ValueError(
|
||||
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
|
||||
)
|
||||
self.apply_config(config)
|
||||
|
||||
def save_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path (str | None): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui (bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
save_yaml_gui(self, self._config_dict)
|
||||
else:
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
# @SafeSlot(str)
|
||||
def _set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
|
||||
Args:
|
||||
gui_id (str): GUI ID.
|
||||
"""
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
|
||||
def get_obj_by_id(self, obj_id: str):
|
||||
if obj_id == self.gui_id:
|
||||
return self
|
||||
|
||||
def get_bec_shortcuts(self):
|
||||
"""Get BEC shortcuts for the widget."""
|
||||
self.dev = self.client.device_manager.devices
|
||||
self.scans = self.client.scans
|
||||
self.queue = self.client.queue
|
||||
self.scan_storage = self.queue.scan_storage
|
||||
self.dap = self.client.dap
|
||||
|
||||
def update_client(self, client) -> None:
|
||||
"""Update the client and device manager from BEC and create object for BEC shortcuts.
|
||||
|
||||
Args:
|
||||
client: BEC client.
|
||||
"""
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@SafeSlot(ConnectionConfig) # TODO can be also dict
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
|
||||
Args:
|
||||
config (ConnectionConfig | dict): Configuration settings.
|
||||
"""
|
||||
gui_id = getattr(config, "gui_id", None)
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
self.config = config
|
||||
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
|
||||
self.config.gui_id = gui_id
|
||||
|
||||
def remove(self):
|
||||
"""Cleanup the BECConnector"""
|
||||
# If the widget is from Qt, trigger its close method.
|
||||
if hasattr(self, "close"):
|
||||
self.close()
|
||||
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
|
||||
# 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:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Args:
|
||||
dict_output (bool): If True, return the configuration as a dictionary.
|
||||
If False, return the configuration as a pydantic model.
|
||||
|
||||
Returns:
|
||||
dict | BaseModel: The configuration of the widget.
|
||||
"""
|
||||
if dict_output:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
|
||||
def export_settings(self) -> dict:
|
||||
"""
|
||||
Export the settings of the widget as dict.
|
||||
|
||||
Returns:
|
||||
dict: The exported settings of the widget.
|
||||
"""
|
||||
|
||||
# We first get all qproperties that were defined in a bec_widgets class
|
||||
objs = self._get_bec_meta_objects()
|
||||
settings = {}
|
||||
for prop_name in objs.keys():
|
||||
try:
|
||||
prop_value = getattr(self, prop_name)
|
||||
settings[prop_name] = prop_value
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not export property '{prop_name}' from '{self.__class__.__name__}': {e}"
|
||||
)
|
||||
return settings
|
||||
|
||||
def load_settings(self, settings: dict) -> None:
|
||||
"""
|
||||
Load the settings of the widget from dict.
|
||||
|
||||
Args:
|
||||
settings (dict): The settings to load into the widget.
|
||||
"""
|
||||
objs = self._get_bec_meta_objects()
|
||||
for prop_name, prop_value in settings.items():
|
||||
if prop_name in objs:
|
||||
try:
|
||||
setattr(self, prop_name, prop_value)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not load property '{prop_name}' into '{self.__class__.__name__}': {e}"
|
||||
)
|
||||
|
||||
def _get_bec_meta_objects(self) -> dict:
|
||||
"""
|
||||
Get BEC meta objects for the widget.
|
||||
|
||||
Returns:
|
||||
dict: BEC meta objects.
|
||||
"""
|
||||
if not isinstance(self, QObject):
|
||||
return {}
|
||||
objects = {}
|
||||
for name, attr in vars(self.__class__).items():
|
||||
if isinstance(attr, Property):
|
||||
# Check if the property is a SafeProperty
|
||||
is_safe_property = getattr(attr.fget, "__is_safe_getter__", False)
|
||||
if is_safe_property:
|
||||
objects[name] = attr
|
||||
return objects
|
||||
|
||||
|
||||
# --- Example usage of BECConnector: running a simple task ---
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
# Create a QApplication instance (required for QThreadPool)
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
connector = BECConnector()
|
||||
|
||||
def print_numbers():
|
||||
"""
|
||||
Task function that prints numbers 1 to 10 with a 0.5 second delay between each.
|
||||
"""
|
||||
for i in range(1, 11):
|
||||
print(i)
|
||||
time.sleep(0.5)
|
||||
|
||||
def task_complete():
|
||||
"""
|
||||
Called when the task is complete.
|
||||
"""
|
||||
print("Task complete")
|
||||
# Exit the application after the task completes.
|
||||
app.quit()
|
||||
|
||||
# Submit the task using the connector's submit_task method.
|
||||
connector.submit_task(print_numbers, on_complete=task_complete)
|
||||
|
||||
# Start the Qt event loop.
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,171 +0,0 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
|
||||
|
||||
def designer_material_icon(icon_name: str) -> QIcon:
|
||||
"""
|
||||
Create a QIcon for the BECDesigner with the given material icon name.
|
||||
|
||||
Args:
|
||||
icon_name (str): The name of the material icon.
|
||||
|
||||
Returns:
|
||||
QIcon: The QIcon for the material icon.
|
||||
"""
|
||||
return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True))
|
||||
|
||||
|
||||
def list_editable_packages() -> set[str]:
|
||||
"""
|
||||
List all editable packages in the environment.
|
||||
|
||||
Returns:
|
||||
set: A set of paths to editable packages.
|
||||
"""
|
||||
|
||||
editable_packages = set()
|
||||
|
||||
# Get site-packages directories
|
||||
site_packages = site.getsitepackages()
|
||||
if hasattr(site, "getusersitepackages"):
|
||||
site_packages.append(site.getusersitepackages())
|
||||
|
||||
for dist in importlib.metadata.distributions():
|
||||
location = dist.locate_file("").resolve()
|
||||
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
|
||||
|
||||
if is_editable:
|
||||
editable_packages.add(str(location))
|
||||
|
||||
for packages in site_packages:
|
||||
# all dist-info directories in site-packages that contain a direct_url.json file
|
||||
dist_info_dirs = Path(packages).rglob("*.dist-info")
|
||||
for dist_info_dir in dist_info_dirs:
|
||||
direct_url = dist_info_dir / "direct_url.json"
|
||||
if not direct_url.exists():
|
||||
continue
|
||||
# load the json file and get the path to the package
|
||||
with open(direct_url, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
path = data.get("url", "")
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
editable_packages.add(path)
|
||||
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
|
||||
init_virtual_env()
|
||||
|
||||
major_version = sys.version_info[0]
|
||||
minor_version = sys.version_info[1]
|
||||
os.environ["PY_MAJOR_VERSION"] = str(major_version)
|
||||
os.environ["PY_MINOR_VERSION"] = str(minor_version)
|
||||
|
||||
if sys.platform == "win32":
|
||||
if is_virtual_env():
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
env_var = "LD_PRELOAD"
|
||||
current_pid = os.getpid()
|
||||
with open(f"/proc/{current_pid}/maps", "rt") as f:
|
||||
for line in f:
|
||||
if "libpython" in line:
|
||||
lib_path = line.split()[-1]
|
||||
os.environ[env_var] = lib_path
|
||||
break
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
"""
|
||||
Recursively find all directories containing a .pyproject file.
|
||||
"""
|
||||
plugin_paths = []
|
||||
for path in base_path.rglob("*.pyproject"):
|
||||
plugin_paths.append(str(path.parent))
|
||||
return plugin_paths
|
||||
|
||||
|
||||
def set_plugin_environment_variable(plugin_paths):
|
||||
"""
|
||||
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
|
||||
"""
|
||||
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
|
||||
if current_paths:
|
||||
current_paths = current_paths.split(os.pathsep)
|
||||
else:
|
||||
current_paths = []
|
||||
|
||||
current_paths.extend(plugin_paths)
|
||||
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def open_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
|
||||
|
||||
plugin_paths = find_plugin_paths(base_dir)
|
||||
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
|
||||
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
|
||||
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer(cmd_args)
|
||||
|
||||
|
||||
def main():
|
||||
open_designer(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,283 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import random
|
||||
import string
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
|
||||
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb: Callable, cb_info: dict | None = None):
|
||||
"""
|
||||
Initialize the QtThreadSafeCallback.
|
||||
|
||||
Args:
|
||||
cb (Callable): The callback function to be wrapped.
|
||||
cb_info (dict, optional): Additional information about the callback. Defaults to None.
|
||||
"""
|
||||
super().__init__()
|
||||
self.cb_info = cb_info
|
||||
|
||||
self.cb = cb
|
||||
self.cb_ref = louie.saferef.safe_ref(cb)
|
||||
self.cb_signal.connect(self.cb)
|
||||
self.topics = set()
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, QtThreadSafeCallback):
|
||||
return False
|
||||
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
if self.cb_ref() is None:
|
||||
# callback has been deleted
|
||||
return
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
class QtRedisConnector(RedisConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _execute_callback(self, cb, msg, kwargs):
|
||||
if not isinstance(cb, QtThreadSafeCallback):
|
||||
return super()._execute_callback(cb, msg, kwargs)
|
||||
# if msg.msg_type == "bundle_message":
|
||||
# # big warning: how to handle bundle messages?
|
||||
# # message with messages inside ; which slot to call?
|
||||
# # bundle_msg = msg
|
||||
# # for msg in bundle_msg:
|
||||
# # ...
|
||||
# # for now, only consider the 1st message
|
||||
# msg = msg[0]
|
||||
# raise RuntimeError(f"
|
||||
if isinstance(msg, MessageObject):
|
||||
if isinstance(msg.value, list):
|
||||
msg = msg.value[0]
|
||||
else:
|
||||
msg = msg.value
|
||||
|
||||
# we can notice kwargs are lost when passed to Qt slot
|
||||
metadata = msg.metadata
|
||||
cb(msg.content, metadata)
|
||||
else:
|
||||
# from stream
|
||||
msg = msg["data"]
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
class BECDispatcher:
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
client: BECClient
|
||||
cli_server: RPCServer | None = None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
client=None,
|
||||
config: str | ServiceConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BECDispatcher, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClient(
|
||||
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
|
||||
)
|
||||
else:
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
logger.info("Shutting down BECClient to switch to QtRedisConnector")
|
||||
self.client.shutdown()
|
||||
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
|
||||
|
||||
try:
|
||||
self.client.start()
|
||||
except redis.exceptions.ConnectionError:
|
||||
logger.warning("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
register_serializer_extension()
|
||||
|
||||
logger.success("Initialized BECDispatcher")
|
||||
|
||||
self.start_cli_server(gui_id=gui_id)
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance of the BECDispatcher.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
|
||||
cb_info: dict | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
"""
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
if qt_slot not in self._registered_slots:
|
||||
self._registered_slots[qt_slot] = qt_slot
|
||||
qt_slot = self._registered_slots[qt_slot]
|
||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
|
||||
def disconnect_slot(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
|
||||
"""
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# but the slot we receive here is the original callable
|
||||
for connected_slot in self._registered_slots.values():
|
||||
if connected_slot.cb == slot:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
|
||||
if not self._registered_slots[connected_slot].topics:
|
||||
del self._registered_slots[connected_slot]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect all slots from a topic.
|
||||
|
||||
Args:
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
|
||||
remove_slots = []
|
||||
for connected_slot in self._registered_slots.values():
|
||||
connected_slot.topics.difference_update(set(topics_str))
|
||||
|
||||
if not connected_slot.topics:
|
||||
remove_slots.append(connected_slot)
|
||||
|
||||
for connected_slot in remove_slots:
|
||||
self._registered_slots.pop(connected_slot, None)
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
Disconnect all slots from all topics.
|
||||
|
||||
Args:
|
||||
*args: Arbitrary positional arguments
|
||||
**kwargs: Arbitrary keyword arguments
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
|
||||
def start_cli_server(self, gui_id: str | None = None):
|
||||
"""
|
||||
Start the CLI server.
|
||||
|
||||
Args:
|
||||
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
if gui_id is None:
|
||||
gui_id = self.generate_unique_identifier()
|
||||
|
||||
if not self.client.started:
|
||||
logger.error("Cannot start CLI server without a running client")
|
||||
return
|
||||
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
|
||||
logger.success(f"Started CLI server with gui_id: {gui_id}")
|
||||
|
||||
def stop_cli_server(self):
|
||||
"""
|
||||
Stop the CLI server.
|
||||
"""
|
||||
if self.cli_server is None:
|
||||
logger.error("Cannot stop CLI server without starting it first")
|
||||
return
|
||||
self.cli_server.shutdown()
|
||||
self.cli_server = None
|
||||
logger.success("Stopped CLI server")
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_identifier(length: int = 4) -> str:
|
||||
"""
|
||||
Generate a unique identifier for the application.
|
||||
|
||||
Args:
|
||||
length: The length of the identifier. Defaults to 4.
|
||||
|
||||
Returns:
|
||||
str: The unique identifier.
|
||||
"""
|
||||
allowed_chars = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choices(allowed_chars, k=length))
|
||||
@@ -1,93 +0,0 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECList(QListWidget):
|
||||
"""List Widget that manages ListWidgetItems with associated widgets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {}
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self._widget_map
|
||||
|
||||
def add_widget_item(self, key: str, widget: QWidget):
|
||||
"""
|
||||
Add a widget to the list, mapping is associated with the given key.
|
||||
|
||||
Args:
|
||||
key (str): Key to associate with the widget.
|
||||
widget (QWidget): Widget to add to the list.
|
||||
"""
|
||||
if key in self._widget_map:
|
||||
self.remove_widget_item(key)
|
||||
|
||||
item = QListWidgetItem()
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self.insertItem(0, item)
|
||||
self.setItemWidget(item, widget)
|
||||
self._widget_map[key] = (item, widget)
|
||||
|
||||
def remove_widget_item(self, key: str):
|
||||
"""
|
||||
Remove a widget by identifier key.
|
||||
|
||||
Args:
|
||||
key (str): Key associated with the widget to remove.
|
||||
"""
|
||||
if key not in self._widget_map:
|
||||
return
|
||||
|
||||
item, widget = self._widget_map.pop(key)
|
||||
row = self.row(item)
|
||||
self.takeItem(row)
|
||||
try:
|
||||
widget.close()
|
||||
except Exception:
|
||||
logger.debug(f"Could not close widget properly for key: {key}.")
|
||||
try:
|
||||
widget.deleteLater()
|
||||
except Exception:
|
||||
logger.debug(f"Could not delete widget properly for key: {key}.")
|
||||
|
||||
def clear_widgets(self):
|
||||
"""Remove and destroy all widget items."""
|
||||
for key in list(self._widget_map.keys()):
|
||||
self.remove_widget_item(key)
|
||||
self._widget_map.clear()
|
||||
self.clear()
|
||||
|
||||
def get_widget(self, key: str) -> QWidget | None:
|
||||
"""Return the widget for a given key."""
|
||||
entry = self._widget_map.get(key)
|
||||
return entry[1] if entry else None
|
||||
|
||||
def get_item(self, key: str) -> QListWidgetItem | None:
|
||||
"""Return the QListWidgetItem for a given key."""
|
||||
entry = self._widget_map.get(key)
|
||||
return entry[0] if entry else None
|
||||
|
||||
def get_widgets(self) -> list[QWidget]:
|
||||
"""Return all managed widgets."""
|
||||
return [w for _, w in self._widget_map.values()]
|
||||
|
||||
def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None:
|
||||
"""Return the widget associated with a given QListWidgetItem."""
|
||||
for itm, widget in self._widget_map.values():
|
||||
if itm == item:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None:
|
||||
"""Return the QListWidgetItem associated with a given widget."""
|
||||
for itm, w in self._widget_map.values():
|
||||
if w == widget:
|
||||
return itm
|
||||
return None
|
||||
|
||||
def get_all_keys(self) -> list[str]:
|
||||
"""Return all keys for managed widgets."""
|
||||
return list(self._widget_map.keys())
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Login dialog for user authentication.
|
||||
The Login Widget is styled in a Material Design style and emits
|
||||
the entered credentials through a signal for further processing.
|
||||
"""
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class BECLogin(QWidget):
|
||||
"""Login dialog for user authentication in Material Design style."""
|
||||
|
||||
credentials_entered = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
# Only displayed if this widget as standalone widget, and not embedded in another widget
|
||||
self.setWindowTitle("Login")
|
||||
|
||||
title = QLabel("Sign in", parent=self)
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title.setStyleSheet("""
|
||||
#QLabel
|
||||
{
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
""")
|
||||
|
||||
self.username = QLineEdit(parent=self)
|
||||
self.username.setPlaceholderText("Username")
|
||||
|
||||
self.password = QLineEdit(parent=self)
|
||||
self.password.setPlaceholderText("Password")
|
||||
self.password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
|
||||
self.ok_btn = QPushButton("Sign in", parent=self)
|
||||
self.ok_btn.setDefault(True)
|
||||
self.ok_btn.clicked.connect(self._emit_credentials)
|
||||
# If the user presses Enter in the password field, trigger the OK button click
|
||||
self.password.returnPressed.connect(self.ok_btn.click)
|
||||
|
||||
# Build Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(32, 32, 32, 32)
|
||||
layout.setSpacing(16)
|
||||
|
||||
layout.addWidget(title)
|
||||
layout.addSpacing(8)
|
||||
layout.addWidget(self.username)
|
||||
layout.addWidget(self.password)
|
||||
layout.addSpacing(12)
|
||||
layout.addWidget(self.ok_btn)
|
||||
|
||||
self.username.setFocus()
|
||||
|
||||
self.setStyleSheet("""
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
def _clear_password(self):
|
||||
"""Clear the password field."""
|
||||
self.password.clear()
|
||||
|
||||
def _emit_credentials(self):
|
||||
"""Emit credentials and clear the password field."""
|
||||
self.credentials_entered.emit(self.username.text().strip(), self.password.text())
|
||||
self._clear_password()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
dialog = BECLogin()
|
||||
|
||||
dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}"))
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,105 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import pkgutil
|
||||
import traceback
|
||||
from importlib import util as importlib_util
|
||||
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
||||
from types import ModuleType
|
||||
from typing import Generator
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
"""Return specs for all submodules of the given module."""
|
||||
return tuple(
|
||||
module_info.module_finder.find_spec(module_info.name)
|
||||
for module_info in pkgutil.iter_modules(module.__path__)
|
||||
if isinstance(module_info.module_finder, FileFinder)
|
||||
)
|
||||
|
||||
|
||||
def _loaded_submodules_from_specs(
|
||||
submodule_specs: tuple[ModuleSpec | None, ...],
|
||||
) -> Generator[ModuleType, None, None]:
|
||||
"""Load all submodules from the given specs."""
|
||||
for submodule in (
|
||||
importlib_util.module_from_spec(spec) for spec in submodule_specs if spec is not None
|
||||
):
|
||||
assert isinstance(
|
||||
submodule.__loader__, SourceFileLoader
|
||||
), "Module found from FileFinder should have SourceFileLoader!"
|
||||
try:
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
except Exception as e:
|
||||
exception_text = "".join(traceback.format_exception(e))
|
||||
if "(most likely due to a circular import)" in exception_text:
|
||||
logger.warning(f"Circular import encountered while loading {submodule}")
|
||||
else:
|
||||
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
|
||||
yield submodule
|
||||
|
||||
|
||||
def _submodule_by_name(module: ModuleType, name: str):
|
||||
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
|
||||
if submod.__name__ == name:
|
||||
return submod
|
||||
return None
|
||||
|
||||
|
||||
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
||||
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
||||
|
||||
classes = inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, 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)
|
||||
for k, v in classes
|
||||
)
|
||||
|
||||
|
||||
def _all_widgets_from_all_submods(module) -> BECClassContainer:
|
||||
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
|
||||
widgets = _get_widgets_from_module(module)
|
||||
if not hasattr(module, "__path__"):
|
||||
return widgets
|
||||
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
|
||||
widgets += _all_widgets_from_all_submods(submod)
|
||||
return widgets
|
||||
|
||||
|
||||
def user_widget_plugin() -> ModuleType | None:
|
||||
plugins = importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
|
||||
return None if len(plugins) == 0 else tuple(plugins)[0].load()
|
||||
|
||||
|
||||
def get_plugin_client_module() -> ModuleType | None:
|
||||
"""If there is a plugin repository installed, return the client module."""
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> BECClassContainer:
|
||||
"""If there is a plugin repository installed, load all widgets from it."""
|
||||
if plugin := user_widget_plugin():
|
||||
return _all_widgets_from_all_submods(plugin)
|
||||
else:
|
||||
return BECClassContainer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
client = get_plugin_client_module()
|
||||
print(get_all_plugin_widgets())
|
||||
...
|
||||
@@ -1,86 +0,0 @@
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import copier
|
||||
import typer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_repo_path
|
||||
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
|
||||
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
|
||||
|
||||
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
|
||||
|
||||
logger = bec_logger.logger
|
||||
_app = typer.Typer(rich_markup_mode="rich")
|
||||
|
||||
|
||||
def _commit_added_widget(repo: Path, name: str):
|
||||
git_stage_files(repo, [".copier-answers.yml"])
|
||||
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
|
||||
make_commit(repo, f"plugin-manager added new widget: {name}")
|
||||
logger.info(f"Committing new widget {name}")
|
||||
|
||||
|
||||
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
|
||||
return name in [w["name"] for w in widget_list]
|
||||
|
||||
|
||||
def _editor_cb(ctx: typer.Context, value: bool):
|
||||
if value and not ctx.params["use_ui"]:
|
||||
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
|
||||
return value
|
||||
|
||||
|
||||
_bold_blue = "\033[34m\033[1m"
|
||||
_off = "\033[0m"
|
||||
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
|
||||
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
|
||||
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
|
||||
Open the created widget .ui file in bec-designer now?"""
|
||||
|
||||
|
||||
@_app.command()
|
||||
def widget(
|
||||
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
|
||||
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
|
||||
open_editor: Annotated[
|
||||
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
|
||||
] = True,
|
||||
):
|
||||
"""Create a new widget plugin with the given name.
|
||||
|
||||
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
|
||||
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
|
||||
bec-designer and the compiled python version will be updated when changes are made and saved."""
|
||||
if (formatted_name := name.lower().replace("-", "_")) != name:
|
||||
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
|
||||
if not formatted_name.isidentifier():
|
||||
logger.error(
|
||||
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
|
||||
)
|
||||
exit(-1)
|
||||
logger.info(f"Adding new widget {formatted_name} to the template...")
|
||||
try:
|
||||
repo = Path(plugin_repo_path())
|
||||
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
|
||||
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
|
||||
logger.error(f"Widget {formatted_name} already exists!")
|
||||
exit(-1)
|
||||
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
|
||||
copier.run_update(
|
||||
repo,
|
||||
data=plugin_data,
|
||||
defaults=True,
|
||||
unsafe=True,
|
||||
overwrite=True,
|
||||
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
|
||||
)
|
||||
_commit_added_widget(repo, formatted_name)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("exiting...")
|
||||
exit(-1)
|
||||
logger.success(f"Added widget {formatted_name}!")
|
||||
if open_editor:
|
||||
open_and_watch_ui_editor(formatted_name)
|
||||
@@ -1,136 +0,0 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from bec_widgets.utils.bec_designer import open_designer
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class RecompileHandler(FileSystemEventHandler):
|
||||
def __init__(self, in_file: Path, out_file: Path) -> None:
|
||||
super().__init__()
|
||||
self.in_file = str(in_file)
|
||||
self.out_file = str(out_file)
|
||||
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
|
||||
self._widget_import_re = re.compile(
|
||||
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
|
||||
)
|
||||
self._widget_modules = {
|
||||
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
|
||||
}
|
||||
|
||||
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def recompile(self, event: FileSystemEvent) -> None:
|
||||
if event.src_path == self.in_file or event.dest_path == self.in_file:
|
||||
self._recompile()
|
||||
|
||||
def _recompile(self):
|
||||
logger.success(".ui file modified, recompiling...")
|
||||
code = subprocess.call(
|
||||
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
|
||||
)
|
||||
logger.success(f"compilation exited with code {code}")
|
||||
if code != 0:
|
||||
return
|
||||
self._add_comment_to_file()
|
||||
logger.success("updating imports...")
|
||||
self._update_imports()
|
||||
logger.success("formatting...")
|
||||
code = subprocess.call(
|
||||
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running black on {self.out_file}, code: {code}")
|
||||
return
|
||||
code = subprocess.call(
|
||||
[
|
||||
"isort",
|
||||
"--line-length=100",
|
||||
"--profile=black",
|
||||
"--multi-line=3",
|
||||
"--trailing-comma",
|
||||
self.out_file,
|
||||
]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
|
||||
return
|
||||
logger.success("done!")
|
||||
|
||||
def _add_comment_to_file(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
|
||||
f.write(
|
||||
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
|
||||
)
|
||||
f.write(initial)
|
||||
|
||||
def _update_imports(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
qtpy_imports = re.sub(
|
||||
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
|
||||
)
|
||||
print(self._widget_modules)
|
||||
print(re.findall(self._widget_import_re, qtpy_imports))
|
||||
widget_imports = re.sub(
|
||||
self._widget_import_re,
|
||||
lambda ob: (
|
||||
f"from {module} import {ob.group(2)}"
|
||||
if (module := self._widget_modules.get(ob.group(2))) is not None
|
||||
else ob.group(1)
|
||||
),
|
||||
qtpy_imports,
|
||||
)
|
||||
f.write(widget_imports)
|
||||
f.truncate()
|
||||
|
||||
|
||||
def open_and_watch_ui_editor(widget_name: str):
|
||||
logger.info(f"Opening the editor for {widget_name}, and watching")
|
||||
repo = Path(plugin_repo_path())
|
||||
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
|
||||
ui_file = widget_dir / f"{widget_name}.ui"
|
||||
ui_outfile = widget_dir / f"{widget_name}_ui.py"
|
||||
|
||||
logger.info(
|
||||
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
|
||||
)
|
||||
recompile_handler = RecompileHandler(ui_file, ui_outfile)
|
||||
observer = Observer()
|
||||
observer.schedule(recompile_handler, str(ui_file.parent))
|
||||
observer.start()
|
||||
try:
|
||||
open_designer([str(ui_file)])
|
||||
finally:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
logger.info("Editing session ended, exiting...")
|
||||
@@ -1,90 +0,0 @@
|
||||
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class BECSignalProxy(SignalProxy):
|
||||
"""
|
||||
Thin wrapper around the SignalProxy class to allow signal calls to be blocked,
|
||||
but arguments still being stored.
|
||||
|
||||
Args:
|
||||
*args: Arguments to pass to the SignalProxy class.
|
||||
rateLimit (int): The rateLimit of the proxy.
|
||||
timeout (float): The number of seconds after which the proxy automatically
|
||||
unblocks if still blocked. Default is 10.0 seconds.
|
||||
**kwargs: Keyword arguments to pass to the SignalProxy class.
|
||||
|
||||
Example:
|
||||
>>> proxy = BECSignalProxy(signal, rate_limit=25, slot=callback)
|
||||
"""
|
||||
|
||||
is_blocked = Signal(bool)
|
||||
|
||||
def __init__(self, *args, rateLimit=25, timeout=10.0, **kwargs):
|
||||
super().__init__(*args, rateLimit=rateLimit, **kwargs)
|
||||
self._blocking = False
|
||||
self.old_args = None
|
||||
self.new_args = None
|
||||
|
||||
# Store timeout value (in seconds)
|
||||
self._timeout = timeout
|
||||
|
||||
# Create a single-shot timer for auto-unblocking
|
||||
self._timer = QTimer()
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout_unblock)
|
||||
|
||||
@property
|
||||
def blocked(self):
|
||||
"""Returns if the proxy is blocked"""
|
||||
return self._blocking
|
||||
|
||||
@blocked.setter
|
||||
def blocked(self, value: bool):
|
||||
self._blocking = value
|
||||
self.is_blocked.emit(value)
|
||||
|
||||
def signalReceived(self, *args):
|
||||
"""Receive signal, store the args and call signalReceived from the parent class if not blocked"""
|
||||
self.new_args = args
|
||||
if self.blocked is True:
|
||||
return
|
||||
self.blocked = True
|
||||
self.old_args = args
|
||||
super().signalReceived(*args)
|
||||
|
||||
self._timer.start(int(self._timeout * 1000))
|
||||
|
||||
@SafeSlot()
|
||||
def unblock_proxy(self):
|
||||
"""Unblock the proxy, and call the signalReceived method in case there was an update of the args."""
|
||||
if self.blocked:
|
||||
self._timer.stop()
|
||||
self.blocked = False
|
||||
if self.new_args != self.old_args:
|
||||
self.signalReceived(*self.new_args)
|
||||
|
||||
@SafeSlot()
|
||||
def _timeout_unblock(self):
|
||||
"""
|
||||
Internal method called by the QTimer upon timeout. Unblocks the proxy
|
||||
automatically if it is still blocked.
|
||||
"""
|
||||
if self.blocked:
|
||||
self.unblock_proxy()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the proxy by stopping the timer and disconnecting the timeout signal.
|
||||
"""
|
||||
self._timer.stop()
|
||||
self._timer.timeout.disconnect(self._timeout_unblock)
|
||||
self._timer.deleteLater()
|
||||
@@ -1,21 +0,0 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QTableWidget
|
||||
|
||||
|
||||
class BECTable(QTableWidget):
|
||||
"""Table widget with custom keyPressEvent to delete rows with backspace or delete key"""
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
"""
|
||||
Delete selected rows with backspace or delete key
|
||||
|
||||
Args:
|
||||
event: keyPressEvent
|
||||
"""
|
||||
if event.key() in (Qt.Key_Backspace, Qt.Key_Delete):
|
||||
selected_ranges = self.selectedRanges()
|
||||
for selected_range in selected_ranges:
|
||||
for row in range(selected_range.topRow(), selected_range.bottomRow() + 1):
|
||||
self.removeRow(row)
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
@@ -1,368 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
|
||||
from qtpy.QtGui import QFont, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||
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
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECWidget(BECConnector):
|
||||
"""Mixin class for all BEC widgets, to handle cleanup"""
|
||||
|
||||
# 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", "attach", "detach"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
start_busy: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
|
||||
|
||||
|
||||
>>> class MyWidget(BECWidget, QWidget):
|
||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||
|
||||
|
||||
Args:
|
||||
client(BECClient, optional): The BEC client.
|
||||
config(ConnectionConfig, optional): The connection configuration.
|
||||
gui_id(str, optional): The GUI ID.
|
||||
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
|
||||
widget's apply_theme method will be called when the theme changes.
|
||||
"""
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
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: "BusyLoaderOverlay" | None = None
|
||||
self._busy_state_widget: QWidget | None = None
|
||||
|
||||
self._loading = False
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
if start_busy and isinstance(self, QWidget):
|
||||
self._show_busy_overlay()
|
||||
self._loading = True
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
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:
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme"):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self._update_overlay_theme(theme)
|
||||
self.apply_theme(theme)
|
||||
|
||||
def create_busy_state_widget(self) -> QWidget:
|
||||
"""
|
||||
Method to create a custom busy state widget to be shown in the busy overlay.
|
||||
Child classes should overrid this method to provide a custom widget if desired.
|
||||
|
||||
Returns:
|
||||
QWidget: The custom busy state widget.
|
||||
|
||||
NOTE:
|
||||
The implementation here is a SpinnerWidget with a "Loading..." label. This is the default
|
||||
busy state widget for all BECWidgets. However, child classes with specific needs for the
|
||||
busy state can easily overrite this method to provide a custom widget. The signature of
|
||||
the method must be preserved to ensure compatibility with the busy overlay system. If
|
||||
the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up.
|
||||
|
||||
The widget may connect to the _busy_overlay signals foreground_color_changed and
|
||||
scrim_color_changed to update its colors when the theme changes.
|
||||
"""
|
||||
|
||||
# Widget
|
||||
class BusyStateWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# label
|
||||
label = QLabel("Loading...", self)
|
||||
label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
f = QFont(label.font())
|
||||
f.setBold(True)
|
||||
f.setPointSize(f.pointSize() + 1)
|
||||
label.setFont(f)
|
||||
|
||||
# spinner
|
||||
spinner = SpinnerWidget(self)
|
||||
spinner.setFixedSize(42, 42)
|
||||
|
||||
# Layout
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(24, 24, 24, 24)
|
||||
lay.setSpacing(10)
|
||||
lay.addStretch(1)
|
||||
lay.addWidget(spinner, 0, Qt.AlignHCenter)
|
||||
lay.addWidget(label, 0, Qt.AlignHCenter)
|
||||
lay.addStretch(1)
|
||||
self.setLayout(lay)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Show event to start the spinner."""
|
||||
super().showEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Hide event to stop the spinner."""
|
||||
super().hideEvent(event)
|
||||
for child in self.findChildren(SpinnerWidget):
|
||||
child.stop()
|
||||
|
||||
widget = BusyStateWidget(self)
|
||||
return widget
|
||||
|
||||
def _install_busy_loader(self) -> "BusyLoaderOverlay" | None:
|
||||
"""
|
||||
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:
|
||||
|
||||
overlay = install_busy_loader(target=self, start_loading=False)
|
||||
self._busy_overlay = overlay
|
||||
|
||||
# Create and set the busy state widget
|
||||
self._busy_state_widget = self.create_busy_state_widget()
|
||||
self._busy_overlay.set_widget(self._busy_state_widget)
|
||||
return overlay
|
||||
|
||||
def _show_busy_overlay(self) -> None:
|
||||
"""Create and attach the loading overlay to this widget if QWidget is present."""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
if self._busy_overlay is not None:
|
||||
self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member
|
||||
self._busy_overlay.raise_()
|
||||
self._busy_overlay.show()
|
||||
|
||||
def set_busy(self, enabled: bool) -> None:
|
||||
"""
|
||||
Set the busy state of the widget. This will show or hide the loading overlay, which will
|
||||
block user interaction with the widget and show the busy_state_widget if provided. Per
|
||||
default, the busy state widget is a spinner with "Loading..." text.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the busy state.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return
|
||||
# If not yet installed, install the busy overlay now together with the busy state widget
|
||||
if self._busy_overlay is None:
|
||||
self._busy_overlay = self._install_busy_loader()
|
||||
if enabled:
|
||||
self._show_busy_overlay()
|
||||
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))
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the widget.
|
||||
|
||||
Args:
|
||||
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:
|
||||
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.
|
||||
"""
|
||||
return ""
|
||||
|
||||
@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 screenshot_bytes(
|
||||
self,
|
||||
*,
|
||||
max_width: int | None = None,
|
||||
max_height: int | None = None,
|
||||
fmt: str = "PNG",
|
||||
quality: int = -1,
|
||||
) -> QByteArray:
|
||||
"""
|
||||
Grab this widget, optionally scale to a max size, and return encoded image bytes.
|
||||
|
||||
If max_width/max_height are omitted (the default), capture at full resolution.
|
||||
|
||||
Args:
|
||||
max_width(int, optional): Maximum width of the screenshot.
|
||||
max_height(int, optional): Maximum height of the screenshot.
|
||||
fmt(str, optional): Image format (e.g., "PNG", "JPEG").
|
||||
quality(int, optional): Image quality (0-100), -1 for default.
|
||||
|
||||
Returns:
|
||||
QByteArray: The screenshot image bytes.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
return QByteArray()
|
||||
|
||||
if not hasattr(self, "grab"):
|
||||
raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}")
|
||||
|
||||
pixmap: QPixmap = self.grab()
|
||||
if pixmap.isNull():
|
||||
return QByteArray()
|
||||
if max_width is not None or max_height is not None:
|
||||
w = max_width if max_width is not None else pixmap.width()
|
||||
h = max_height if max_height is not None else pixmap.height()
|
||||
pixmap = pixmap.scaled(
|
||||
w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation
|
||||
)
|
||||
ba = QByteArray()
|
||||
buf = QBuffer(ba)
|
||||
buf.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buf, fmt, quality)
|
||||
buf.close()
|
||||
return ba
|
||||
|
||||
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}")
|
||||
|
||||
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
|
||||
|
||||
overlay.cleanup()
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
if not self._destroyed:
|
||||
self.cleanup()
|
||||
self._destroyed = True
|
||||
finally:
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
@@ -1,325 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
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 not hasattr(self, "_target") or self._target is None:
|
||||
return False
|
||||
if not hasattr(self, "_overlay") or self._overlay is None:
|
||||
return False
|
||||
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.
|
||||
"""
|
||||
|
||||
foreground_color_changed = Signal(QColor)
|
||||
scrim_color_changed = Signal(QColor)
|
||||
|
||||
def __init__(self, parent: QWidget, opacity: float = 0.35, **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._scrim_color = QColor(128, 128, 128, 110)
|
||||
self._label_color = QColor(240, 240, 240)
|
||||
self._filter: QObject | None = None
|
||||
|
||||
# Set Main Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(10)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Custom widget placeholder
|
||||
self._custom_widget: QWidget | None = None
|
||||
|
||||
# Add a frame around the content
|
||||
self._frame = QFrame(self)
|
||||
self._frame.setObjectName("busyFrame")
|
||||
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self._frame.lower()
|
||||
|
||||
# Defaults
|
||||
self._update_palette()
|
||||
|
||||
# Start hidden; interactions beneath are blocked while visible
|
||||
self.hide()
|
||||
|
||||
@SafeProperty(QColor, notify=scrim_color_changed)
|
||||
def scrim_color(self) -> QColor:
|
||||
"""
|
||||
The overlay scrim color.
|
||||
"""
|
||||
return self._scrim_color
|
||||
|
||||
@scrim_color.setter
|
||||
def scrim_color(self, value: QColor):
|
||||
if not isinstance(value, QColor):
|
||||
raise TypeError("scrim_color must be a QColor")
|
||||
self._scrim_color = value
|
||||
self.update()
|
||||
|
||||
@SafeProperty(QColor, notify=foreground_color_changed)
|
||||
def foreground_color(self) -> QColor:
|
||||
"""
|
||||
The overlay foreground color (text, spinner).
|
||||
"""
|
||||
return self._label_color
|
||||
|
||||
@foreground_color.setter
|
||||
def foreground_color(self, value: QColor):
|
||||
if not isinstance(value, QColor):
|
||||
try:
|
||||
color = QColor(value)
|
||||
if not color.isValid():
|
||||
raise ValueError(f"Invalid color: {value}")
|
||||
except Exception:
|
||||
# pylint: disable=raise-missing-from
|
||||
raise ValueError(f"Color {value} is invalid, cannot be converted to QColor")
|
||||
self._label_color = value
|
||||
self.update()
|
||||
|
||||
def set_filter(self, filt: _OverlayEventFilter):
|
||||
"""
|
||||
Set an event filter to keep the overlay sized and stacked over its target.
|
||||
|
||||
Args:
|
||||
filt(QObject): The event filter instance.
|
||||
"""
|
||||
self._filter = filt
|
||||
target = filt._target
|
||||
if self.parent() != target:
|
||||
logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}")
|
||||
target.installEventFilter(self._filter)
|
||||
|
||||
######################
|
||||
### Public methods ###
|
||||
######################
|
||||
|
||||
def set_widget(self, widget: QWidget):
|
||||
"""
|
||||
Set a custom widget as an overlay for the busy overlay.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The custom widget to display.
|
||||
"""
|
||||
lay = self.layout()
|
||||
if lay is None:
|
||||
return
|
||||
self._custom_widget = widget
|
||||
lay.addWidget(widget, 0, Qt.AlignHCenter)
|
||||
|
||||
def set_opacity(self, opacity: float):
|
||||
"""
|
||||
Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a
|
||||
value outside this range is provided, it will be clamped.
|
||||
|
||||
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
|
||||
base = self.scrim_color
|
||||
base.setAlpha(int(255 * self._opacity))
|
||||
self.scrim_color = base
|
||||
self._update_palette()
|
||||
|
||||
##########################
|
||||
### Internal methods ###
|
||||
##########################
|
||||
|
||||
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]
|
||||
_bg = theme.color("BORDER")
|
||||
_fg = theme.color("FG")
|
||||
else:
|
||||
# Fallback neutrals
|
||||
_bg = QColor(30, 30, 30)
|
||||
_fg = QColor(230, 230, 230)
|
||||
|
||||
# Semi-transparent scrim derived from bg
|
||||
base = _bg if isinstance(_bg, QColor) else QColor(str(_bg))
|
||||
base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
|
||||
self.scrim_color = base
|
||||
fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg))
|
||||
self.foreground_color = fg
|
||||
|
||||
# Set the frame style with updated foreground colors
|
||||
r, g, b, a = base.getRgb()
|
||||
self._frame.setStyleSheet(
|
||||
f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba({r}, {g}, {b}, {a}); }}"
|
||||
)
|
||||
self.update()
|
||||
|
||||
#############################
|
||||
### Custom Event Handlers ###
|
||||
#############################
|
||||
|
||||
def showEvent(self, e):
|
||||
# Call showEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.showEvent(e)
|
||||
super().showEvent(e)
|
||||
|
||||
def hideEvent(self, e):
|
||||
# Call hideEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.hideEvent(e)
|
||||
super().hideEvent(e)
|
||||
|
||||
def resizeEvent(self, e):
|
||||
# Call resizeEvent on custom widget if present
|
||||
if self._custom_widget is not None:
|
||||
self._custom_widget.resizeEvent(e)
|
||||
super().resizeEvent(e)
|
||||
r = self.rect().adjusted(10, 10, -10, -10)
|
||||
self._frame.setGeometry(r)
|
||||
|
||||
# TODO should we have this cleanup here?
|
||||
def cleanup(self):
|
||||
"""Cleanup resources used by the overlay."""
|
||||
if self._custom_widget is not None:
|
||||
if hasattr(self._custom_widget, "cleanup"):
|
||||
self._custom_widget.cleanup()
|
||||
|
||||
|
||||
def install_busy_loader(
|
||||
target: QWidget, 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.
|
||||
start_loading(bool): If True, show the overlay immediately.
|
||||
opacity(float): Overlay opacity (0..1).
|
||||
|
||||
Returns:
|
||||
BusyLoaderOverlay: The overlay instance.
|
||||
"""
|
||||
overlay = BusyLoaderOverlay(parent=target, opacity=opacity)
|
||||
overlay.setGeometry(target.rect())
|
||||
overlay.set_filter(_OverlayEventFilter(target=target, overlay=overlay))
|
||||
if start_loading:
|
||||
overlay.show()
|
||||
return overlay
|
||||
|
||||
|
||||
# --------------------------
|
||||
# Launchable demo
|
||||
# --------------------------
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
class DemoWidget(BECWidget, QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None, start_busy: bool = False):
|
||||
super().__init__(parent=parent, theme_update=True, start_busy=start_busy)
|
||||
|
||||
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): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Busy Loader — BECWidget demo")
|
||||
|
||||
left = DemoWidget(start_busy=True)
|
||||
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))
|
||||
btn_off.clicked.connect(lambda: right.set_busy(False))
|
||||
|
||||
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)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
w = DemoWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
from qtpy.QtWidgets import QLabel
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
|
||||
self.clicked.emit()
|
||||
return super().mouseReleaseEvent(ev)
|
||||
@@ -1,380 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property, QEasingCurve, QObject, QPropertyAnimation
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
|
||||
|
||||
class DimensionAnimator(QObject):
|
||||
"""
|
||||
Helper class to animate the size of a panel widget.
|
||||
"""
|
||||
|
||||
def __init__(self, panel_widget: QWidget, direction: str):
|
||||
super().__init__()
|
||||
self.panel_widget = panel_widget
|
||||
self.direction = direction
|
||||
self._size = 0
|
||||
|
||||
@Property(int)
|
||||
def panel_width(self):
|
||||
"""
|
||||
Returns the current width of the panel widget.
|
||||
"""
|
||||
return self._size
|
||||
|
||||
@panel_width.setter
|
||||
def panel_width(self, val: int):
|
||||
"""
|
||||
Set the width of the panel widget.
|
||||
|
||||
Args:
|
||||
val(int): The width to set.
|
||||
"""
|
||||
self._size = val
|
||||
self.panel_widget.setFixedWidth(val)
|
||||
|
||||
@Property(int)
|
||||
def panel_height(self):
|
||||
"""
|
||||
Returns the current height of the panel widget.
|
||||
"""
|
||||
return self._size
|
||||
|
||||
@panel_height.setter
|
||||
def panel_height(self, val: int):
|
||||
"""
|
||||
Set the height of the panel widget.
|
||||
|
||||
Args:
|
||||
val(int): The height to set.
|
||||
"""
|
||||
self._size = val
|
||||
self.panel_widget.setFixedHeight(val)
|
||||
|
||||
|
||||
class CollapsiblePanelManager(QObject):
|
||||
"""
|
||||
Manager class to handle collapsible panels from a main widget using LayoutManagerWidget.
|
||||
"""
|
||||
|
||||
def __init__(self, layout_manager: LayoutManagerWidget, reference_widget: QWidget, parent=None):
|
||||
super().__init__(parent)
|
||||
self.layout_manager = layout_manager
|
||||
self.reference_widget = reference_widget
|
||||
self.animations = {}
|
||||
self.panels = {}
|
||||
self.direction_settings = {
|
||||
"left": {"property": b"maximumWidth", "default_size": 200},
|
||||
"right": {"property": b"maximumWidth", "default_size": 200},
|
||||
"top": {"property": b"maximumHeight", "default_size": 150},
|
||||
"bottom": {"property": b"maximumHeight", "default_size": 150},
|
||||
}
|
||||
|
||||
def add_panel(
|
||||
self,
|
||||
direction: Literal["left", "right", "top", "bottom"],
|
||||
panel_widget: QWidget,
|
||||
target_size: int | None = None,
|
||||
duration: int = 300,
|
||||
):
|
||||
"""
|
||||
Add a panel widget to the layout manager.
|
||||
|
||||
Args:
|
||||
direction(Literal["left", "right", "top", "bottom"]): Direction of the panel.
|
||||
panel_widget(QWidget): The panel widget to add.
|
||||
target_size(int, optional): The target size of the panel. Defaults to None.
|
||||
duration(int): The duration of the animation in milliseconds. Defaults to 300.
|
||||
"""
|
||||
if direction not in self.direction_settings:
|
||||
raise ValueError("Direction must be one of 'left', 'right', 'top', 'bottom'.")
|
||||
|
||||
if target_size is None:
|
||||
target_size = self.direction_settings[direction]["default_size"]
|
||||
|
||||
self.layout_manager.add_widget_relative(
|
||||
widget=panel_widget, reference_widget=self.reference_widget, position=direction
|
||||
)
|
||||
panel_widget.setVisible(False)
|
||||
|
||||
# Set initial constraints as flexible
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMaximumWidth(0)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMaximumHeight(0)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
self.panels[direction] = {
|
||||
"widget": panel_widget,
|
||||
"direction": direction,
|
||||
"target_size": target_size,
|
||||
"duration": duration,
|
||||
"animator": None,
|
||||
}
|
||||
|
||||
def toggle_panel(
|
||||
self,
|
||||
direction: Literal["left", "right", "top", "bottom"],
|
||||
target_size: int | None = None,
|
||||
duration: int | None = None,
|
||||
easing_curve: QEasingCurve = QEasingCurve.InOutQuad,
|
||||
ensure_max: bool = False,
|
||||
scale: float | None = None,
|
||||
animation: bool = True,
|
||||
):
|
||||
"""
|
||||
Toggle the specified panel.
|
||||
|
||||
Parameters:
|
||||
direction (Literal["left", "right", "top", "bottom"]): Direction of the panel to toggle.
|
||||
target_size (int, optional): Override target size for this toggle.
|
||||
duration (int, optional): Override the animation duration.
|
||||
easing_curve (QEasingCurve): Animation easing curve.
|
||||
ensure_max (bool): If True, animate as a fixed-size panel.
|
||||
scale (float, optional): If provided, calculate target_size from main widget size.
|
||||
animation (bool): If False, no animation is performed; panel instantly toggles.
|
||||
"""
|
||||
if direction not in self.panels:
|
||||
raise ValueError(f"No panel found in direction '{direction}'.")
|
||||
|
||||
panel_info = self.panels[direction]
|
||||
panel_widget = panel_info["widget"]
|
||||
dir_settings = self.direction_settings[direction]
|
||||
|
||||
# Determine final target size
|
||||
if scale is not None:
|
||||
main_rect = self.reference_widget.geometry()
|
||||
if direction in ["left", "right"]:
|
||||
computed_target = int(main_rect.width() * scale)
|
||||
else:
|
||||
computed_target = int(main_rect.height() * scale)
|
||||
final_target_size = computed_target
|
||||
else:
|
||||
if target_size is None:
|
||||
final_target_size = panel_info["target_size"]
|
||||
else:
|
||||
final_target_size = target_size
|
||||
|
||||
if duration is None:
|
||||
duration = panel_info["duration"]
|
||||
|
||||
expanding_property = dir_settings["property"]
|
||||
currently_visible = panel_widget.isVisible()
|
||||
|
||||
if ensure_max:
|
||||
if panel_info["animator"] is None:
|
||||
panel_info["animator"] = DimensionAnimator(panel_widget, direction)
|
||||
animator = panel_info["animator"]
|
||||
|
||||
if direction in ["left", "right"]:
|
||||
prop_name = b"panel_width"
|
||||
else:
|
||||
prop_name = b"panel_height"
|
||||
else:
|
||||
animator = None
|
||||
prop_name = expanding_property
|
||||
|
||||
if currently_visible:
|
||||
# Hide the panel
|
||||
if ensure_max:
|
||||
start_value = final_target_size
|
||||
end_value = 0
|
||||
finish_callback = lambda w=panel_widget, d=direction: self._after_hide_reset(w, d)
|
||||
else:
|
||||
start_value = (
|
||||
panel_widget.width()
|
||||
if direction in ["left", "right"]
|
||||
else panel_widget.height()
|
||||
)
|
||||
end_value = 0
|
||||
finish_callback = lambda w=panel_widget: w.setVisible(False)
|
||||
else:
|
||||
# Show the panel
|
||||
start_value = 0
|
||||
end_value = final_target_size
|
||||
finish_callback = None
|
||||
if ensure_max:
|
||||
# Fix panel exactly
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMinimumWidth(0)
|
||||
panel_widget.setMaximumWidth(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMinimumHeight(0)
|
||||
panel_widget.setMaximumHeight(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
else:
|
||||
# Flexible mode
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMinimumWidth(0)
|
||||
panel_widget.setMaximumWidth(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMinimumHeight(0)
|
||||
panel_widget.setMaximumHeight(final_target_size)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
panel_widget.setVisible(True)
|
||||
|
||||
if not animation:
|
||||
# No animation: instantly set final state
|
||||
if end_value == 0:
|
||||
# Hiding
|
||||
if ensure_max:
|
||||
# Reset after hide
|
||||
self._after_hide_reset(panel_widget, direction)
|
||||
else:
|
||||
panel_widget.setVisible(False)
|
||||
else:
|
||||
# Showing
|
||||
if ensure_max:
|
||||
# Already set fixed size
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setFixedWidth(end_value)
|
||||
else:
|
||||
panel_widget.setFixedHeight(end_value)
|
||||
else:
|
||||
# Just set maximum dimension
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMaximumWidth(end_value)
|
||||
else:
|
||||
panel_widget.setMaximumHeight(end_value)
|
||||
return
|
||||
|
||||
# With animation
|
||||
animation = QPropertyAnimation(animator if ensure_max else panel_widget, prop_name)
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(start_value)
|
||||
animation.setEndValue(end_value)
|
||||
animation.setEasingCurve(easing_curve)
|
||||
|
||||
if end_value == 0 and finish_callback:
|
||||
animation.finished.connect(finish_callback)
|
||||
elif end_value == 0 and not finish_callback:
|
||||
animation.finished.connect(lambda w=panel_widget: w.setVisible(False))
|
||||
|
||||
animation.start()
|
||||
self.animations[panel_widget] = animation
|
||||
|
||||
@typechecked
|
||||
def _after_hide_reset(
|
||||
self, panel_widget: QWidget, direction: Literal["left", "right", "top", "bottom"]
|
||||
):
|
||||
"""
|
||||
Reset the panel widget after hiding it in ensure_max mode.
|
||||
|
||||
Args:
|
||||
panel_widget(QWidget): The panel widget to reset.
|
||||
direction(Literal["left", "right", "top", "bottom"]): The direction of the panel.
|
||||
"""
|
||||
# Called after hiding a panel in ensure_max mode
|
||||
panel_widget.setVisible(False)
|
||||
if direction in ["left", "right"]:
|
||||
panel_widget.setMinimumWidth(0)
|
||||
panel_widget.setMaximumWidth(0)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
else:
|
||||
panel_widget.setMinimumHeight(0)
|
||||
panel_widget.setMaximumHeight(16777215)
|
||||
panel_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# The following code is for the GUI control panel to interact with the CollapsiblePanelManager.
|
||||
# It is not covered by any tests as it serves only as an example for the CollapsiblePanelManager class.
|
||||
####################################################################################################
|
||||
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Panels with ensure_max, scale, and animation toggle")
|
||||
self.resize(800, 600)
|
||||
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
main_layout = QVBoxLayout(central_widget)
|
||||
main_layout.setContentsMargins(10, 10, 10, 10)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# Buttons
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.btn_left = QPushButton("Toggle Left (ensure_max=True)")
|
||||
self.btn_top = QPushButton("Toggle Top (scale=0.5, no animation)")
|
||||
self.btn_right = QPushButton("Toggle Right (ensure_max=True, scale=0.3)")
|
||||
self.btn_bottom = QPushButton("Toggle Bottom (no animation)")
|
||||
|
||||
buttons_layout.addWidget(self.btn_left)
|
||||
buttons_layout.addWidget(self.btn_top)
|
||||
buttons_layout.addWidget(self.btn_right)
|
||||
buttons_layout.addWidget(self.btn_bottom)
|
||||
|
||||
main_layout.addLayout(buttons_layout)
|
||||
|
||||
self.layout_manager = LayoutManagerWidget()
|
||||
main_layout.addWidget(self.layout_manager)
|
||||
|
||||
# Main widget
|
||||
self.main_plot = pg.PlotWidget()
|
||||
self.main_plot.plot([1, 2, 3, 4], [4, 3, 2, 1])
|
||||
self.layout_manager.add_widget(self.main_plot, 0, 0)
|
||||
|
||||
self.panel_manager = CollapsiblePanelManager(self.layout_manager, self.main_plot)
|
||||
|
||||
# Panels
|
||||
self.left_panel = pg.PlotWidget()
|
||||
self.left_panel.plot([1, 2, 3], [3, 2, 1])
|
||||
self.panel_manager.add_panel("left", self.left_panel, target_size=200)
|
||||
|
||||
self.right_panel = pg.PlotWidget()
|
||||
self.right_panel.plot([10, 20, 30], [1, 10, 1])
|
||||
self.panel_manager.add_panel("right", self.right_panel, target_size=200)
|
||||
|
||||
self.top_panel = pg.PlotWidget()
|
||||
self.top_panel.plot([1, 2, 3], [1, 2, 3])
|
||||
self.panel_manager.add_panel("top", self.top_panel, target_size=150)
|
||||
|
||||
self.bottom_panel = pg.PlotWidget()
|
||||
self.bottom_panel.plot([2, 4, 6], [10, 5, 10])
|
||||
self.panel_manager.add_panel("bottom", self.bottom_panel, target_size=150)
|
||||
|
||||
# Connect buttons
|
||||
# Left with ensure_max
|
||||
self.btn_left.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("left", ensure_max=True)
|
||||
)
|
||||
# Top with scale=0.5 and no animation
|
||||
self.btn_top.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("top", scale=0.5, animation=False)
|
||||
)
|
||||
# Right with ensure_max, scale=0.3
|
||||
self.btn_right.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("right", ensure_max=True, scale=0.3)
|
||||
)
|
||||
# Bottom no animation
|
||||
self.btn_bottom.clicked.connect(
|
||||
lambda: self.panel_manager.toggle_panel("bottom", target_size=100, animation=False)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
w = MainWindow()
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,646 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import apply_theme as apply_theme_global
|
||||
from bec_qthemes._theme import AccentColors
|
||||
from pydantic_core import PydanticCustomError
|
||||
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
|
||||
from qtpy.QtCore import QEvent, QEventLoop
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return "dark"
|
||||
else:
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
# FIXME this is legacy code, should be removed in the future
|
||||
app = QApplication.instance()
|
||||
palette = app.palette()
|
||||
return palette
|
||||
|
||||
|
||||
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"):
|
||||
accent_colors = AccentColors()
|
||||
return accent_colors
|
||||
return QApplication.instance().theme.accent_colors
|
||||
|
||||
|
||||
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 via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||
"""
|
||||
logger.info(f"Applying theme: {theme}")
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
apply_theme_global(theme)
|
||||
process_all_deferred_deletes(QApplication.instance())
|
||||
|
||||
|
||||
class Colors:
|
||||
@staticmethod
|
||||
def list_available_colormaps() -> list[str]:
|
||||
"""
|
||||
List colormap names available via the pyqtgraph colormap registry.
|
||||
|
||||
Note: This does not include `GradientEditorItem` presets (used by HistogramLUT menus).
|
||||
"""
|
||||
|
||||
def _list(source: str | None = None) -> list[str]:
|
||||
try:
|
||||
return pg.colormap.listMaps() if source is None else pg.colormap.listMaps(source)
|
||||
except Exception: # pragma: no cover - backend may be missing
|
||||
return []
|
||||
|
||||
return [*_list(None), *_list("matplotlib"), *_list("colorcet")]
|
||||
|
||||
@staticmethod
|
||||
def list_available_gradient_presets() -> list[str]:
|
||||
"""
|
||||
List `GradientEditorItem` preset names (HistogramLUT right-click menu entries).
|
||||
"""
|
||||
from pyqtgraph.graphicsItems.GradientEditorItem import Gradients
|
||||
|
||||
return list(Gradients.keys())
|
||||
|
||||
@staticmethod
|
||||
def canonical_colormap_name(color_map: str) -> str:
|
||||
"""
|
||||
Return an available colormap/preset name if a case-insensitive match exists.
|
||||
"""
|
||||
requested = (color_map or "").strip()
|
||||
if not requested:
|
||||
return requested
|
||||
|
||||
registry = Colors.list_available_colormaps()
|
||||
presets = Colors.list_available_gradient_presets()
|
||||
available = set(registry) | set(presets)
|
||||
|
||||
if requested in available:
|
||||
return requested
|
||||
|
||||
# Case-insensitive match.
|
||||
requested_lc = requested.casefold()
|
||||
|
||||
for name in available:
|
||||
if name.casefold() == requested_lc:
|
||||
return name
|
||||
|
||||
return requested
|
||||
|
||||
@staticmethod
|
||||
def get_colormap(color_map: str) -> pg.ColorMap:
|
||||
"""
|
||||
Resolve a string into a `pg.ColorMap` using either:
|
||||
- the `pg.colormap` registry (optionally including matplotlib/colorcet backends), or
|
||||
- `GradientEditorItem` presets (HistogramLUT right-click menu).
|
||||
"""
|
||||
name = Colors.canonical_colormap_name(color_map)
|
||||
if not name:
|
||||
raise ValueError("Empty colormap name")
|
||||
|
||||
return Colors._get_colormap_cached(name)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=256)
|
||||
def _get_colormap_cached(name: str) -> pg.ColorMap:
|
||||
# 1) Registry/backends
|
||||
try:
|
||||
cmap = pg.colormap.get(name)
|
||||
if cmap is not None:
|
||||
return cmap
|
||||
except Exception:
|
||||
pass
|
||||
for source in ("matplotlib", "colorcet"):
|
||||
try:
|
||||
cmap = pg.colormap.get(name, source=source)
|
||||
if cmap is not None:
|
||||
return cmap
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 2) Presets -> ColorMap
|
||||
|
||||
if name not in Gradients:
|
||||
raise KeyError(f"Colormap '{name}' not found")
|
||||
|
||||
ge = pg.GradientEditorItem()
|
||||
ge.loadPreset(name)
|
||||
|
||||
return ge.colorMap()
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
|
||||
Returns:
|
||||
list: List of angles calculated using the golden ratio.
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
|
||||
"""
|
||||
Set the theme offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
|
||||
Args:
|
||||
theme(str): The theme to be applied.
|
||||
offset(float): Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
|
||||
Returns:
|
||||
tuple: Tuple of min_pos and max_pos.
|
||||
|
||||
Raises:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
|
||||
if offset < 0 or offset > 1:
|
||||
raise ValueError("theme_offset must be between 0 and 1")
|
||||
|
||||
if theme is None:
|
||||
app = QApplication.instance()
|
||||
if hasattr(app, "theme"):
|
||||
theme = app.theme.theme
|
||||
|
||||
if theme == "light":
|
||||
min_pos = 0.0
|
||||
max_pos = 1 - offset
|
||||
else:
|
||||
min_pos = 0.0 + offset
|
||||
max_pos = 1.0
|
||||
|
||||
return min_pos, max_pos
|
||||
|
||||
@staticmethod
|
||||
def evenly_spaced_colors(
|
||||
colormap: str,
|
||||
num: int,
|
||||
format: Literal["QColor", "HEX", "RGB"] = "QColor",
|
||||
theme_offset=0.2,
|
||||
theme: Literal["light", "dark"] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Extract `num` colors from the specified colormap, evenly spaced along its range,
|
||||
and return them in the specified format.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap.
|
||||
num (int): Number of requested colors.
|
||||
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
|
||||
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
theme (Literal['light', 'dark'] | None): The theme to be applied. Overrides the QApplication theme if specified.
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
Raises:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
if theme_offset < 0 or theme_offset > 1:
|
||||
raise ValueError("theme_offset must be between 0 and 1")
|
||||
|
||||
cmap = Colors.get_colormap(colormap)
|
||||
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
|
||||
|
||||
# Generate positions that are evenly spaced within the acceptable range
|
||||
if num == 1:
|
||||
positions = np.array([(min_pos + max_pos) / 2])
|
||||
else:
|
||||
positions = np.linspace(min_pos, max_pos, num)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(
|
||||
colormap: str,
|
||||
num: int,
|
||||
format: Literal["QColor", "HEX", "RGB"] = "QColor",
|
||||
theme_offset=0.2,
|
||||
theme: Literal["dark", "light"] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap.
|
||||
num (int): Number of requested colors.
|
||||
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
|
||||
theme_offset (float): Has to be between 0-1. Offset to avoid colors too close to white or black with light or dark theme respectively for pyqtgraph plot background.
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
Raises:
|
||||
ValueError: If theme_offset is not between 0 and 1.
|
||||
"""
|
||||
|
||||
cmap = Colors.get_colormap(colormap)
|
||||
phi = (1 + np.sqrt(5)) / 2 # Golden ratio
|
||||
golden_angle_conjugate = 1 - (1 / phi) # Approximately 0.38196601125
|
||||
|
||||
min_pos, max_pos = Colors.set_theme_offset(theme, theme_offset)
|
||||
|
||||
# Generate positions within the acceptable range
|
||||
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
|
||||
positions = min_pos + positions * (max_pos - min_pos)
|
||||
|
||||
# Sample colors from the colormap at the calculated positions
|
||||
colors = cmap.map(positions, mode="float")
|
||||
color_list = []
|
||||
|
||||
for color in colors:
|
||||
if format.upper() == "HEX":
|
||||
color_list.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
color_list.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
color_list.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return color_list
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
"""
|
||||
Convert HEX color to RGBA.
|
||||
|
||||
Args:
|
||||
hex_color(str): HEX color string.
|
||||
alpha(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
tuple: RGBA color tuple (r, g, b, a).
|
||||
"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
|
||||
return (r, g, b, a)
|
||||
else:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
Validate the color input if it is HEX or RGBA compatible. Can be used in any pydantic model as a field validator.
|
||||
|
||||
Args:
|
||||
color(tuple|str): The color to be validated. Can be a tuple of RGBA values or a HEX string.
|
||||
|
||||
Returns:
|
||||
tuple|str: The validated color.
|
||||
"""
|
||||
CSS_COLOR_NAMES = {
|
||||
"aliceblue",
|
||||
"antiquewhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedalmond",
|
||||
"blue",
|
||||
"blueviolet",
|
||||
"brown",
|
||||
"burlywood",
|
||||
"cadetblue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerblue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkblue",
|
||||
"darkcyan",
|
||||
"darkgoldenrod",
|
||||
"darkgray",
|
||||
"darkgreen",
|
||||
"darkgrey",
|
||||
"darkkhaki",
|
||||
"darkmagenta",
|
||||
"darkolivegreen",
|
||||
"darkorange",
|
||||
"darkorchid",
|
||||
"darkred",
|
||||
"darksalmon",
|
||||
"darkseagreen",
|
||||
"darkslateblue",
|
||||
"darkslategray",
|
||||
"darkslategrey",
|
||||
"darkturquoise",
|
||||
"darkviolet",
|
||||
"deeppink",
|
||||
"deepskyblue",
|
||||
"dimgray",
|
||||
"dimgrey",
|
||||
"dodgerblue",
|
||||
"firebrick",
|
||||
"floralwhite",
|
||||
"forestgreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostwhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"green",
|
||||
"greenyellow",
|
||||
"grey",
|
||||
"honeydew",
|
||||
"hotpink",
|
||||
"indianred",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderblush",
|
||||
"lawngreen",
|
||||
"lemonchiffon",
|
||||
"lightblue",
|
||||
"lightcoral",
|
||||
"lightcyan",
|
||||
"lightgoldenrodyellow",
|
||||
"lightgray",
|
||||
"lightgreen",
|
||||
"lightgrey",
|
||||
"lightpink",
|
||||
"lightsalmon",
|
||||
"lightseagreen",
|
||||
"lightskyblue",
|
||||
"lightslategray",
|
||||
"lightslategrey",
|
||||
"lightsteelblue",
|
||||
"lightyellow",
|
||||
"lime",
|
||||
"limegreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"mediumaquamarine",
|
||||
"mediumblue",
|
||||
"mediumorchid",
|
||||
"mediumpurple",
|
||||
"mediumseagreen",
|
||||
"mediumslateblue",
|
||||
"mediumspringgreen",
|
||||
"mediumturquoise",
|
||||
"mediumvioletred",
|
||||
"midnightblue",
|
||||
"mintcream",
|
||||
"mistyrose",
|
||||
"moccasin",
|
||||
"navajowhite",
|
||||
"navy",
|
||||
"oldlace",
|
||||
"olive",
|
||||
"olivedrab",
|
||||
"orange",
|
||||
"orangered",
|
||||
"orchid",
|
||||
"palegoldenrod",
|
||||
"palegreen",
|
||||
"paleturquoise",
|
||||
"palevioletred",
|
||||
"papayawhip",
|
||||
"peachpuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderblue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosybrown",
|
||||
"royalblue",
|
||||
"saddlebrown",
|
||||
"salmon",
|
||||
"sandybrown",
|
||||
"seagreen",
|
||||
"seashell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyblue",
|
||||
"slateblue",
|
||||
"slategray",
|
||||
"slategrey",
|
||||
"snow",
|
||||
"springgreen",
|
||||
"steelblue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whitesmoke",
|
||||
"yellow",
|
||||
"yellowgreen",
|
||||
}
|
||||
if isinstance(color, str):
|
||||
hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
|
||||
if hex_pattern.match(color):
|
||||
return color
|
||||
elif color.lower() in CSS_COLOR_NAMES:
|
||||
return color
|
||||
else:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a valid HEX string or CSS Color.",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
elif isinstance(color, tuple):
|
||||
if len(color) != 4:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a tuple of 4 elements (R, G, B, A).",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
for value in color:
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
f"The color values must be between 0 and 255 in RGBA format (R,G,B,A)",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
return color
|
||||
|
||||
@staticmethod
|
||||
def validate_color_map(color_map: str, return_error: bool = True) -> str | bool:
|
||||
"""
|
||||
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
|
||||
|
||||
Args:
|
||||
color_map(str): The colormap to be validated.
|
||||
|
||||
Returns:
|
||||
str: The validated colormap, if colormap is valid.
|
||||
bool: False, if colormap is invalid.
|
||||
|
||||
Raises:
|
||||
PydanticCustomError: If colormap is invalid.
|
||||
"""
|
||||
normalized = Colors.canonical_colormap_name(color_map)
|
||||
try:
|
||||
Colors.get_colormap(normalized)
|
||||
except Exception as ext:
|
||||
logger.warning(f"Colormap validation error: {ext}")
|
||||
if return_error:
|
||||
available_colormaps = sorted(
|
||||
set(Colors.list_available_colormaps())
|
||||
| set(Colors.list_available_gradient_presets())
|
||||
)
|
||||
raise PydanticCustomError(
|
||||
"unsupported colormap",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose from the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
else:
|
||||
return False
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def relative_luminance(color: QColor) -> float:
|
||||
"""
|
||||
Calculate the relative luminance of a QColor according to WCAG 2.0 standards.
|
||||
See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance.
|
||||
|
||||
Args:
|
||||
color(QColor): The color to calculate the relative luminance for.
|
||||
|
||||
Returns:
|
||||
float: The relative luminance of the color.
|
||||
"""
|
||||
r = color.red() / 255.0
|
||||
g = color.green() / 255.0
|
||||
b = color.blue() / 255.0
|
||||
|
||||
def adjust(c):
|
||||
if c <= 0.03928:
|
||||
return c / 12.92
|
||||
return ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r = adjust(r)
|
||||
g = adjust(g)
|
||||
b = adjust(b)
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
@staticmethod
|
||||
def _tint_strength(
|
||||
accent: QColor, background: QColor, min_tint: float = 0.06, max_tint: float = 0.18
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the tint strength based on the contrast between the accent and background colors.
|
||||
min_tint and max_tint define the range of tint strength and are empirically chosen.
|
||||
|
||||
Args:
|
||||
accent(QColor): The accent color.
|
||||
background(QColor): The background color.
|
||||
min_tint(float): The minimum tint strength.
|
||||
max_tint(float): The maximum tint strength.
|
||||
|
||||
Returns:
|
||||
float: The tint strength between 0 and 1.
|
||||
"""
|
||||
l_accent = Colors.relative_luminance(accent)
|
||||
l_bg = Colors.relative_luminance(background)
|
||||
|
||||
contrast = abs(l_accent - l_bg)
|
||||
|
||||
# normalize contrast to a value between 0 and 1
|
||||
t = min(contrast / 0.9, 1.0)
|
||||
return min_tint + t * (max_tint - min_tint)
|
||||
|
||||
@staticmethod
|
||||
def _blend(background: QColor, accent: QColor, t: float) -> QColor:
|
||||
"""
|
||||
Blend two colors based on a tint strength t.
|
||||
"""
|
||||
return QColor(
|
||||
round(background.red() + (accent.red() - background.red()) * t),
|
||||
round(background.green() + (accent.green() - background.green()) * t),
|
||||
round(background.blue() + (accent.blue() - background.blue()) * t),
|
||||
round(background.alpha() + (accent.alpha() - background.alpha()) * t),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def subtle_background_color(accent: QColor, background: QColor) -> QColor:
|
||||
"""
|
||||
Generate a subtle, contrast-safe background color derived from an accent color.
|
||||
|
||||
Args:
|
||||
accent(QColor): The accent color.
|
||||
background(QColor): The background color.
|
||||
Returns:
|
||||
QColor: The generated subtle background color.
|
||||
"""
|
||||
if not accent.isValid() or not background.isValid():
|
||||
return background
|
||||
|
||||
tint = Colors._tint_strength(accent, background)
|
||||
return Colors._blend(background, accent, tint)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user