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

Compare commits

..

11 Commits

Author SHA1 Message Date
abf159ae18 wip 2024-07-24 19:56:35 +02:00
f7b050b588 wip 2024-07-24 19:53:18 +02:00
509bf47b7e wip 2024-07-24 19:52:30 +02:00
f81c5e2e67 wip 2024-07-24 18:58:28 +02:00
5a33755dfb wip 2024-07-24 18:52:28 +02:00
fac378b220 wip 2024-07-24 18:49:36 +02:00
d5d61cf034 wip 2024-07-24 18:46:55 +02:00
999069dbb1 wip 2024-07-24 18:42:54 +02:00
5e8679d608 wip 2024-07-24 18:38:32 +02:00
a765714563 ci: wip 2024-07-24 18:33:06 +02:00
e5085d97ea ci: added core dump artifact 2024-07-24 15:30:31 +02:00
935 changed files with 19127 additions and 127600 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: ./bec/logs/*.log
retention-days: 7

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -64,9 +64,6 @@ coverage.xml
.pytest_cache/
cover/
# Output from end2end testing
tests/reference_failures/
# Translations
*.mo
*.pot

268
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,268 @@
# This file is a template, and might need editing before it works on your project.
# Official language image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/python/tags/
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
#commands to run in the Docker container before starting each job.
variables:
DOCKER_TLS_CERTDIR: ""
BEC_CORE_BRANCH: "main"
OPHYD_DEVICES_BRANCH: "main"
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_PIPELINE_SOURCE == "pipeline"
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
include:
- template: Security/Secret-Detection.gitlab-ci.yml
- project: "bec/awi_utils"
file: "/templates/check-packages-job.yml"
inputs:
stage: test
path: "."
pytest_args: "-v --random-order tests/"
exclude_packages: ""
# different stages in the pipeline
stages:
- Formatter
- test
- AdditionalTests
- End2End
- Deploy
.install-qt-webengine-deps: &install-qt-webengine-deps
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *install-qt-webengine-deps
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
formatter:
stage: Formatter
needs: []
script:
- pip install black isort
- isort --check --diff ./
- black --check --diff --color ./
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint:
stage: Formatter
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev,pyqt6]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
- echo "Pylint score is $PYLINT_SCORE"
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
pylint-check:
stage: Formatter
needs: []
allow_failure: true
before_script:
- pip install pylint pylint-exit anybadge
- apt-get update
- apt-get install -y bc
script:
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
- echo "Changed Python files:"
- $CHANGED_FILES
# Run pylint only on changed files
- mkdir ./pylint
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
- echo "Pylint score is $PYLINT_SCORE"
# Fail the job if the pylint score is below 9
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
artifacts:
paths:
- ./pylint/
expire_in: 1 week
rules:
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
tests:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,pyqt6]
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
- coverage report
- coverage xml
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/reference_failures/
when: always
test-matrix:
parallel:
matrix:
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt6"
stage: AdditionalTests
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
script:
- ulimit -c unlimited
- echo "/builds/bec/bec_widgets/core.%e.%p" > /proc/sys/kernel/core_pattern
- cat /proc/sys/kernel/core_pattern
- *clone-repos
- *install-os-packages
- pip install -e ./bec/bec_lib[dev]
- pip install -e ./bec/bec_ipython_client
- pip install -e .[dev,$QT_PCKG]
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
allow_failure: true
artifacts:
# upload core dumps
when: on_failure
paths:
- $PWD/core.*
expire_in: 1 week
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- conda config --prepend channels conda-forge
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11
- conda init bash
- source ~/.bashrc
- conda activate test-environment
- cd ./bec
- source ./bin/install_bec_dev.sh -t
- pip install -e ./bec_lib[dev]
- pip install -e ./bec_ipython_client[dev]
- cd ../
- pip install -e .[dev,pyqt6]
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order
artifacts:
when: on_failure
paths:
- ./logs/*.log
expire_in: 1 week
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
semver:
stage: Deploy
needs: ["tests"]
script:
- git config --global user.name "ci_update_bot"
- git config --global user.email "ci_update_bot@bec.ch"
- git checkout "$CI_COMMIT_REF_NAME"
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
# delete all local tags
- git tag -l | xargs git tag -d
- git fetch --tags
- git tag
# build and publish package
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
- semantic-release publish
allow_failure: false
rules:
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
pages:
stage: Deploy
needs: ["semver"]
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: "$CI_COMMIT_TAG != null"
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
script:
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/

View File

@@ -0,0 +1,17 @@
## Bug report
## Summary
[Provide a brief description of the bug.]
## Expected Behavior vs Actual Behavior
[Describe what you expected to happen and what actually happened.]
## Steps to Reproduce
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
## Related Issues
[Paste links to any related issues or feature requests.]

View File

@@ -1,13 +1,3 @@
---
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]

View File

@@ -1,13 +1,3 @@
---
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]
@@ -47,3 +37,4 @@ assignees: ''
## 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]

View File

@@ -1,24 +1,19 @@
## Description
[Provide a brief description of the changes introduced by this pull request.]
[Provide a brief description of the changes introduced by this merge 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`.]
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `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.]
[Describe any potential side effects or risks of merging this MR.]
## Screenshots / GIFs (if applicable)

View File

@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.11
py-version=3.10
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.

View File

@@ -7,13 +7,13 @@ version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
os: ubuntu-20.04
tools:
python: "3.11"
python: "3.10"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
configuration: docs/conf.py
# If using Sphinx, optionally build your docs in additional formats such as PDF
# formats:
@@ -21,7 +21,5 @@ sphinx:
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
- method: pip
path: .[dev]
install:
- requirements: docs/requirements.txt

10012
CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2025, Paul Scherrer Institute
Copyright (c) 2023, bec
All rights reserved.
Redistribution and use in source and binary forms, with or without

226
README.md
View File

@@ -1,200 +1,74 @@
![banner_opti](https://github.com/user-attachments/assets/44e483be-3f0d-4eb0-bd98-613157456b81)
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
plugin system.
## 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`: dragdock, tab,
split, and export profiles/workspaces for reuse.
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
`BECIPythonClient`.
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
with the custom BEC loader for a zeroglue workflow.
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
acquisition history.
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
or override behavior as needed.
## Table of Contents
- [Installation](#installation)
- [Features](#features)
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
- [Widget Library](#widget-library)
- [Documentation](#documentation)
- [License](#license)
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
## Installation
Use any of the following setups:
### Stable release
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
```bash
pip install bec_widgets
pip install bec_widgets PyQt6
```
### From source (recommended for development)
For development purposes, you can clone the repository and install the package locally in editable mode:
```bash
git clone https://github.com/bec-project/bec_widgets.git
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec_widgets
pip install -e .[dev]
pip install -e .[dev,pyqt6]
```
## Features
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
Python Qt distributions manually.
### 1. Dock area interface: build GUIs in seconds
To select a specific Python Qt distribution, install the package with an additional tag:
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
operate immediately and refine later with RPC or Designer if needed.
![dock_area_example](https://github.com/user-attachments/assets/219a2806-19a8-4a07-9734-b7b554850833)
### 2. Qt Designer plugins + BEC Launcher (no glue)
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
visually in Designer, save a `.ui`, then launch it with
the **BEC Launcher**—no glue code. Widgets autoconnect to BEC/Redis on startup, so your UI is operational immediately.
![designer_opti](https://github.com/user-attachments/assets/fed4843c-1cce-438a-b41f-6636fa5e1545)
### 3. Robust RPC from CLI & remote scripting
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
active devices, topics)—so your UI can be heavily automated.
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
actions,switch profiles, open diagnostic views, or start specific scans.
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
![rpc_opti](https://github.com/user-attachments/assets/666be7fb-9a0d-44c2-8d44-2f9d1dae4497)
### 4. Rapid development (extensible by design)
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
Designer plugin that are immediately usable with your BEC setup. Widgets
come online with live BEC/Redis wiring out of the box.
<details>
<summary> View code: Example Widget </summary>
```python
from typing import Literal
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
from qtpy.QtCore import Slot
from bec_lib.endpoints import MessageEndpoints
from bec_widgets import BECWidget, SafeSlot
class SimpleMotorWidget(BECWidget, QWidget):
USER_ACCESS = ["move"]
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
super().__init__(parent=parent, **kwargs)
self.motor_name = motor_name
self.step = float(step)
self.get_bec_shortcuts()
self.value_label = QLabel(f"{self.motor_name}: —")
self.btn_left = QPushButton("◀︎ -5")
self.btn_right = QPushButton("+5 ▶︎")
row = QHBoxLayout()
row.addWidget(self.btn_left)
row.addWidget(self.btn_right)
col = QVBoxLayout(self)
col.addWidget(self.value_label)
col.addLayout(row)
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
@SafeSlot(dict, dict)
def on_readback(self, data: dict, meta: dict):
current_value = data.get("signals").get(self.motor_name).get('value')
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
@Slot(str, float)
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
if direction == "left":
self.dev[self.motor_name].move(-step, relative=True)
else:
self.dev[self.motor_name].move(step, relative=True)
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
w = SimpleMotorWidget(motor_name="samx", step=5.0)
w.setWindowTitle("MotorJogWidget")
w.resize(280, 90)
w.show()
sys.exit(app.exec_())
```bash
pip install bec_widgets[pyqt6]
```
or
</details>
## Widget Library
A large and growing catalog—plug, configure, run:
### Plotting
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
history data.
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
### Scan orchestration and motion control.
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
Positioner boxes and tweak controls handle precise moves, homing, and calibration for daytoday alignment.
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
```bash
pip install bec_widgets[pyside6]
```
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## Contributing
All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
> ```
> <type>(<scope>): <short summary>
> │ │ │
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
> │ │
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
> │ elements|forms|http|language-service|localize|platform-browser|
> │ platform-browser-dynamic|platform-server|router|service-worker|
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
> │ devtools
>
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ```
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
> Must be one of the following:
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
> * **feat**: A new feature
> * **fix**: A bug fix
> * **perf**: A code change that improves performance
> * **refactor**: A code change that neither fixes a bug nor adds a feature
> * **test**: Adding missing tests or correcting existing tests
## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)

View File

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

View File

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

View File

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

View File

@@ -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 perinstance 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 yoffset.
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 mainlabel 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())

View File

@@ -1,414 +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.admin_view.admin_view import AdminView
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.admin_view = AdminView(self)
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,
# )
self.add_view(
icon="admin_panel_settings",
title="Admin View",
widget=self.admin_view,
mini_text="Admin",
from_top=False,
)
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:
# Special handling for views that can not be switched to (e.g. dark mode toggle)
# Not registered as proper view with a stack index, so we ignore any logic below
# as it will anyways not result in a stack switch.
idx = self._view_index.get(vid)
if idx is None or not (0 <= idx < self.stack.count()):
return
# 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 macros.",
)
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])
app.setApplicationName("BEC")
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()

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
"""Module for Admin View."""
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
class AdminView(ViewBase):
"""
A view for administrators to change the current active experiment, manage messaging
services, and more tasks reserved for users with admin privileges.
"""
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)
self.admin_widget = BECAtlasAdminView(parent=self)
self.set_content(self.admin_widget)
@SafeSlot()
def on_exit(self) -> None:
"""Called before the view is hidden.
Default implementation does nothing. Override in subclasses.
"""
self.admin_widget.logout()

View File

@@ -1,140 +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()
# 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_())

View File

@@ -1,432 +0,0 @@
from __future__ import annotations
import re
import markdown
from bec_lib.endpoints import MessageEndpoints
from bec_lib.script_executor import upload_script
from bec_qthemes import material_icon
from qtpy.QtGui import QKeySequence, QShortcut
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.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._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
self.plotting_ads_dock = self.new(
self.plotting_ads,
where="right",
closable=False,
floatable=False,
movable=False,
return_dock=True,
title_buttons={"float": True},
)
self.signature_dock = self.new(
self.signature_help,
closable=False,
floatable=False,
movable=False,
tab_with=self.plotting_ads_dock,
return_dock=True,
title_buttons={"float": False, "close": False},
)
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)
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")
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)
@SafeSlot()
def on_execute(self):
"""Upload and run the currently focused script in the Monaco editor."""
self.script_editor_tab = self.monaco.last_focused_editor
if not self.script_editor_tab:
return
widget = self.script_editor_tab.widget()
if not isinstance(widget, MonacoWidget):
return
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
self.current_script_id = upload_script(self.client.connector, widget.get_text())
self.console.write(f'bec._run_script("{self.current_script_id}")')
print(f"Uploaded script with ID: {self.current_script_id}")
@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_())

View File

@@ -1,2 +0,0 @@
from .config_choice_dialog import ConfigChoiceDialog
from .device_form_dialog import DeviceFormDialog

View File

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

View File

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

View File

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

View File

@@ -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 separate 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, connection_status in validation_results.values():
if connection_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_())

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
<path d="M479.85-265.87q19.8 0 32.69-12.46 12.9-12.46 12.9-32.26 0-19.8-12.75-32.98-12.74-13.17-32.54-13.17-19.8 0-32.69 13.16-12.9 13.15-12.9 32.95 0 19.8 12.75 32.28 12.74 12.48 32.54 12.48Zm-36.46-166.56h79.22v-262.61h-79.22v262.61Zm36.95 366.56q-86.2 0-161.5-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.73-32.39-75.3-32.39-161.59t32.39-161.67q32.4-75.37 88.75-131.34t131.69-88.62q75.34-32.65 161.67-32.65 86.34 0 161.78 32.61 75.45 32.6 131.37 88.5 55.93 55.89 88.55 131.45 32.63 75.56 32.63 161.87 0 86.29-32.65 161.58t-88.62 131.48q-55.97 56.18-131.42 88.76-75.46 32.58-161.67 32.58Z"/>
</svg>

After

Width:  |  Height:  |  Size: 718 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
<path d="m759.04-283.09-63.13-62q49.31-9.43 84.44-46.02 35.13-36.59 35.13-86.14 0-55.49-39.42-95.08-39.43-39.58-94.93-39.58h-153.3v-79.79h152.74q89.28 0 151.7 62.71Q894.7-566.28 894.7-477q0 63.7-38.26 115.96-38.27 52.26-97.4 77.95ZM596.83-443.61l-65.66-66.78h110.05v66.78h-44.39ZM804.96-56 58.48-802.48 106-850l746.48 746.48L804.96-56ZM443.22-265.87H279.43q-89.28 0-151.7-62.42Q65.3-390.72 65.3-480q0-72.57 43.09-129.54 43.09-56.98 112.09-76.07l70.13 70.7h-11.18q-55.73 0-95.32 39.3-39.59 39.31-39.59 95.61t39.66 95.61q39.66 39.3 95.5 39.3h163.54v79.22ZM319.35-446.61v-66.78h77.3l66.78 66.78H319.35Z"/>
</svg>

After

Width:  |  Height:  |  Size: 721 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFF55">
<path d="M478.3-145.87q-138.65 0-236.39-97.74-97.74-97.74-97.74-236.25t97.74-236.68q97.74-98.16 236.39-98.16 88.4 0 155.45 35.76 67.04 35.76 115.86 98.9V-814.7h66.78v274.92H540.91V-606h165.74q-38.56-57.74-95.3-93.33-56.74-35.58-133.05-35.58-106.88 0-180.89 73.98-74.02 73.99-74.02 180.83 0 106.84 74.02 180.93 74.02 74.08 180.91 74.08 80.16 0 147.74-46.08 67.59-46.09 95.16-121.83H803q-29.56 110.65-119.67 178.89-90.1 68.24-205.03 68.24Z"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#75FB4C">
<path d="m419.87-289.52 289.22-289.22-57.31-56.87L419.87-403.7 304.96-518.61l-56.31 56.87 171.22 172.22Zm60.21 223.65q-85.47 0-161.01-32.39-75.53-32.4-131.97-88.84-56.44-56.44-88.84-131.89-32.39-75.46-32.39-160.93 0-86.47 32.39-162.01 32.4-75.53 88.75-131.5t131.85-88.62q75.5-32.65 161.01-32.65 86.52 0 162.12 32.61 75.61 32.6 131.53 88.5 55.93 55.89 88.55 131.45Q894.7-566.58 894.7-480q0 85.55-32.65 161.07-32.65 75.53-88.62 131.9-55.97 56.37-131.42 88.77-75.46 32.39-161.93 32.39Z"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#F19E39">
<path d="M27.56-112.65 480-894.7l452.44 782.05H27.56Zm456.62-125.48q13.15 0 22.61-9.64 9.47-9.65 9.47-22.8t-9.64-22.33q-9.65-9.19-22.8-9.19t-22.61 9.36q-9.47 9.36-9.47 22.51 0 13.15 9.64 22.62 9.65 9.47 22.8 9.47ZM454-348h60v-219.48h-60V-348Z"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M440.39-440.39H185.87v-79.22h254.52V-774.7h79.22v255.09H774.7v79.22H519.61v254.52h-79.22v-254.52Z"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="m145.26-88.13-57.13-57.13 137.39-137.39H105.87v-79.22h256v256h-79.22v-119.65L145.26-88.13Zm669.48 0L677.91-225.52v119.65h-79.78v-256H854.7v79.22H734.48l137.39 137.39-57.13 57.13Zm-708.87-510v-79.78h119.65L88.13-814.74l57.13-57.13 137.39 137.39V-854.7h79.22v256.57h-256Zm492.26 0V-854.7h79.78v120.22l137.83-138.39 57.13 57.13-138.39 137.83H854.7v79.78H598.13Z"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M114.02-114.02v-308.13h68.13v192.02l547.72-547.72H537.85v-68.37h308.37v308.37h-68.37v-192.02L230.13-182.15h192.02v68.13H114.02Z"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="m311.5-154.02-47.74-47.74 116.94-116.7H74.02v-68.37H380.7L263.76-503.76l47.74-47.74 198.98 198.74L311.5-154.02Zm337-254.72L449.76-607.48 648.5-806.22l47.74 47.74-116.7 116.94h306.68v68.37H579.54l116.7 116.69-47.74 47.74Z"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-2zm-3-4h8v2H8z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M403.39-158.74 82.13-480l321.26-321.26v642.52Zm153.22 0v-642.52L878.44-480 556.61-158.74Zm81.57-195.74L763.13-480 638.18-605.52v251.04Z"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M480-54 303.43-230.56 361-288.13l79.39 78.83v-231.09H209.3L283.13-366l-57.57 57.57L54-480l172.56-172.57L284.13-595l-74.83 75.39h231.09v-231.65L366-676.87l-57.57-57.57L480-906l171.57 171.56L594-676.87l-74.39-74.39v231.65h231.65L676.87-594l57.57-57.57L906-480 734.44-308.43 676.87-366l74.39-74.39H519.61v231.09L599-288.13l57.57 57.57L480-54Z"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
fill="#FFFFFF">
<g>
<rect fill="none" height="24" width="24"/>
</g>
<g>
<path d="M18,15v3H6v-3H4v3c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-3H18z M7,9l1.41,1.41L11,7.83V16h2V7.83l2.59,2.58L17,9l-5-5L7,9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 96" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<rect id="Artboard1" x="0" y="0" width="100" height="96.486" style="fill:none;"/>
<g id="Artboard11" serif:id="Artboard1">
<path d="M11.379,24.832C11.379,24.261 11.843,23.798 12.414,23.798C18.11,23.798 20.117,19.072 22.06,14.503C23.704,10.634 25.403,6.634 29.483,6.634C33.902,6.634 35.376,12.91 36.934,19.555C38.473,26.113 40.065,32.893 44.138,32.893C48.25,32.893 50.78,28.962 53.457,24.8C56.279,20.412 59.198,15.876 64.31,15.876C69.322,15.876 72.165,20.305 74.915,24.588C77.707,28.935 80.343,33.04 84.999,33.04C85.571,33.04 86.034,33.503 86.034,34.075C86.034,34.647 85.571,35.11 84.999,35.11C79.212,35.11 76.004,30.115 73.175,25.708C70.612,21.716 68.192,17.946 64.31,17.946C60.328,17.946 57.835,21.819 55.197,25.92C52.338,30.366 49.381,34.963 44.138,34.963C38.425,34.963 36.643,27.371 34.92,20.028C33.613,14.46 32.262,8.703 29.482,8.703C26.953,8.703 25.71,11.2 23.963,15.311C21.964,20.014 19.479,25.867 12.413,25.867C11.843,25.867 11.379,25.403 11.379,24.832M44.361,44.584C43.504,44.584 42.557,44.882 42.557,45.739L42.586,50.703L39.522,50.703L43.922,61.878L48.807,50.703L45.691,50.703L45.604,46.255C45.602,45.398 45.218,44.584 44.361,44.584ZM6.034,37.487L6.034,6.674L5,6.674L5,38.522L95,38.522L95,37.487L6.034,37.487M77.414,91.881L77.414,63.849C77.414,63.277 76.951,62.814 76.379,62.814C75.808,62.814 75.345,63.277 75.345,63.849L75.345,91.881C75.345,92.045 75.391,92.194 75.458,92.332L61.955,92.332C62.022,92.194 62.068,92.045 62.068,91.881L62.068,82.718C62.068,82.146 61.605,81.683 61.034,81.683C60.462,81.683 59.999,82.146 59.999,82.718L59.999,91.881C59.999,92.045 60.045,92.194 60.112,92.332L45.059,92.332C45.126,92.194 45.172,92.045 45.172,91.881L45.172,75.943C45.172,75.372 44.709,74.909 44.138,74.909C43.567,74.909 43.104,75.372 43.104,75.943L43.104,91.881C43.104,92.045 43.15,92.194 43.217,92.332L23.852,92.332C23.92,92.194 23.966,92.045 23.966,91.881L23.966,63.849C23.966,63.277 23.502,62.814 22.931,62.814C22.36,62.814 21.897,63.277 21.897,63.849L21.897,91.881C21.897,92.045 21.943,92.194 22.011,92.332L6.034,92.332L6.034,62.881L5,62.881L5,93.366L95,93.366L95,92.332L77.301,92.332C77.368,92.194 77.414,92.045 77.414,91.881"
style="fill:white;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="m78.89-112.59 263.02-367.17h202l303.5-354.22v721.39H78.89Zm62.24-262.74-54.7-39.78 166.59-233.02h201L640.46-864.8l51.45 44.26-205.82 240.78H287.33l-146.2 204.43Zm70.54 194.37h567.37v-468.78L574.98-411.63H376.22L211.67-180.96Zm567.37 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h589.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v589.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09Zm43.56-166.04h503.7L578-481.48l-132 171-93-127-124.35 165.57Z"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M283.85-289.91h67.41l42.32-112.66h172.59l43.33 112.66h68.85l-164-428h-67.5l-163 428Zm127.74-165.72 67.34-182.87H481l68.41 182.87H411.59Zm68.53 381.61q-86.32 0-160.51-31t-128.89-85.7q-54.7-54.7-85.7-128.89-31-74.19-31-160.51 0-85.31 30.94-159.4t85.7-128.9q54.76-54.8 128.95-86.3t160.51-31.5q85.31 0 159.42 31.47 74.1 31.47 128.91 86.27 54.82 54.8 86.29 128.88 31.48 74.08 31.48 159.6 0 86.2-31.5 160.39-31.5 74.19-86.3 128.95-54.81 54.76-128.9 85.7-74.09 30.94-159.4 30.94ZM480-480Zm-.04 337.85q144.08 0 240.99-96.74 96.9-96.74 96.9-241.07 0-144.32-96.86-241.11-96.86-96.78-240.95-96.78-144.08 0-240.99 96.74-96.9 96.74-96.9 241.07 0 144.32 96.86 241.11 96.86 96.78 240.95 96.78Z"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
fill="#FFFFFF">
<g>
<rect fill="none" height="24" width="24"/>
</g>
<g>
<path d="M18,15v3H6v-3H4v3c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-3H18z M17,11l-1.41-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5 L17,11z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M110.39-110.39v-89.57l77.52-77.52v167.09h-77.52Zm165.57 0v-250.13l77.52-77.52v327.65h-77.52Zm165.56 0v-327.65l77.52 77.95v249.7h-77.52Zm165.57 0v-250.83l77.52-76.96v327.79h-77.52Zm165.56 0v-411.83l76.96-76.96v488.79h-76.96ZM110.39-335.65v-112.7L400-735.96l160 160 289.61-290.61v112.14L560-463.26l-160-160-289.61 287.61Z"/>
</svg>

After

Width:  |  Height:  |  Size: 450 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M725.93-155.93q0-118.18-45-222.09t-122-180.91q-77-77-180.91-122t-222.09-45v-68.14q132.68 0 248.61 50.23 115.92 50.23 202.5 136.75 86.57 86.53 136.8 202.53 50.23 116.01 50.23 248.63h-68.14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M571.91-279.09h191v-194h-60v134h-131v60ZM198.09-486.91h60v-134h131v-60h-191v194Zm-53 341.04q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-79.22h669.82v-509.82H145.09v509.82Zm0 0v-509.82 509.82Z"/>
</svg>

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M443.78-28.43v-75Q305.65-118 211.54-212.11q-94.11-94.11-108.11-231.67h-75v-72.44h75Q118-654.35 212.11-748.46q94.11-94.11 231.67-108.11v-75h72.44v75q137.56 14 231.67 108.11Q842-654.35 856.57-516.22h75v72.44h-75q-14 137.56-108.11 231.67Q654.35-118 516.22-103.43v75h-72.44Zm36.12-152.66q123.4 0 211.21-87.7 87.8-87.71 87.8-211.11 0-123.4-87.7-211.21-87.71-87.8-211.11-87.8-123.4 0-211.21 87.7-87.8 87.71-87.8 211.11 0 123.4 87.7 211.21 87.71 87.8 211.11 87.8ZM480-330q-63 0-106.5-43.5T330-480q0-63 43.5-106.5T480-630q63 0 106.5 43.5T630-480q0 63-43.5 106.5T480-330Z"/>
</svg>

After

Width:  |  Height:  |  Size: 693 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M354.61-386.61h391l-127-171-103 135-68-87-93 123ZM274.7-195.48q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-549.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h549.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v549.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H274.7Zm0-79.22h549.82v-549.82H274.7v549.82ZM135.48-55.69q-32.74 0-56.26-23.53-23.53-23.52-23.53-56.26v-629.04h79.79v629.04h629.04v79.79H135.48ZM274.7-824.52v549.82-549.82Z"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M480-65.87q-87.51 0-162.97-31.89-75.47-31.89-131.43-87.84-55.95-55.96-87.84-131.43Q65.87-392.49 65.87-480q0-87.47 31.88-162.87 31.89-75.39 87.75-131.51 55.87-56.11 131.41-88.21Q392.44-894.7 480-894.7q15.96 0 27.78 12.16 11.83 12.16 11.83 28.07 0 15.9-11.83 27.73-11.82 11.83-27.78 11.83-139.31 0-237.11 97.8-97.8 97.8-97.8 237.1 0 139.31 97.8 237.12 97.8 97.8 237.1 97.8 139.31 0 237.12-97.8 97.8-97.8 97.8-237.11 0-15.96 11.83-27.78 11.83-11.83 27.73-11.83 15.91 0 28.07 11.83Q894.7-495.96 894.7-480q0 87.56-32.14 163.1-32.14 75.54-88.11 131.44-55.97 55.9-131.44 87.74Q567.55-65.87 480-65.87Z"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M854.7-105.87H105.87v-209.61H854.7v209.61Zm0-269.61H105.87v-209.04H854.7v209.04Zm0-269.04H105.87V-854.7H854.7v210.18Z"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
<path d="M444.19-421.3q39.72 0 67.4-27.78 27.67-27.78 27.67-66.91 0-39.14-27.71-66.57Q483.83-610 444.4-610q-39.44 0-66.92 27.28Q350-555.44 350-516.3q0 39.13 27.36 67.06 27.35 27.94 66.83 27.94ZM634.52-274 532.78-375.74q-22.56 12.31-44.41 19.02-21.84 6.72-43.28 6.72-69.57 0-117.7-48.62-48.13-48.62-48.13-117.88 0-67.98 48.29-116.39t117.08-48.41q68.79 0 117.08 48.41Q610-584.48 610-515.75q0 21.72-6.93 44.13-6.94 22.4-20.37 44.97l102.73 102.74L634.52-274ZM185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87V-352h79.22v166.91H352v79.22H185.09Zm422.91 0v-79.22h166.91V-352h79.79v166.91q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H608ZM105.87-608v-166.91q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53H352v79.79H185.09V-608h-79.22Zm669.04 0v-166.91H608v-79.79h166.91q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26V-608h-79.79Z"/>
</svg>

After

Width:  |  Height:  |  Size: 946 B

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