1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-12 11:40:54 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
David Perl
bcc246cf92 WIP drag and drop form widget 2025-04-17 16:33:40 +02:00
David Perl
550753078b WIP look at form widgets 2025-04-17 10:49:57 +02:00
528 changed files with 10662 additions and 67552 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,342 +0,0 @@
import functools
import os
from typing import Literal
import requests
from github import Github
from pydantic import BaseModel
class GHConfig(BaseModel):
token: str
organization: str
repository: str
project_number: int
graphql_url: str
rest_url: str
headers: dict
class ProjectItemHandler:
"""
A class to handle GitHub project items.
"""
def __init__(self, gh_config: GHConfig):
self.gh_config = gh_config
self.gh = Github(gh_config.token)
self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}")
self.project_node_id = self.get_project_node_id()
def set_issue_status(
self,
status: Literal[
"Selected for Development",
"Weekly Backlog",
"In Development",
"Ready For Review",
"On Hold",
"Done",
],
issue_number: int | None = None,
issue_node_id: str | None = None,
):
"""
Set the status field of a GitHub issue in the project.
Args:
status (str): The status to set. Must be one of the predefined statuses.
issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided.
issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided.
"""
if not issue_number and not issue_node_id:
raise ValueError("Either issue_number or issue_node_id must be provided.")
if issue_number and issue_node_id:
raise ValueError("Only one of issue_number or issue_node_id must be provided.")
if issue_number is not None:
issue = self.repo.get_issue(issue_number)
issue_id = self.get_issue_info(issue.node_id)[0]["id"]
else:
issue_id = issue_node_id
field_id, option_id = self.get_status_field_id(field_name=status)
self.set_field_option(issue_id, field_id, option_id)
def run_graphql(self, query: str, variables: dict) -> dict:
"""
Execute a GraphQL query against the GitHub API.
Args:
query (str): The GraphQL query to execute.
variables (dict): The variables to pass to the query.
Returns:
dict: The response from the GitHub API.
"""
response = requests.post(
self.gh_config.graphql_url,
json={"query": query, "variables": variables},
headers=self.gh_config.headers,
timeout=10,
)
if response.status_code != 200:
raise Exception(
f"Query failed with status code {response.status_code}: {response.text}"
)
return response.json()
def get_project_node_id(self):
"""
Retrieve the project node ID from the GitHub API.
"""
query = """
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
}
}
}
"""
variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number}
resp = self.run_graphql(query, variables)
return resp["data"]["organization"]["projectV2"]["id"]
def get_issue_info(self, issue_node_id: str):
"""
Get the project-related information for a given issue node ID.
Args:
issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I".
Returns:
list[dict]: A list of project items associated with the issue.
"""
query = """
query($issueId: ID!) {
node(id: $issueId) {
... on Issue {
projectItems(first: 10) {
nodes {
project {
id
title
}
id
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2SingleSelectField {
name
}
}
}
}
}
}
}
}
}
}
"""
variables = {"issueId": issue_node_id}
resp = self.run_graphql(query, variables)
return resp["data"]["node"]["projectItems"]["nodes"]
def get_status_field_id(
self,
field_name: Literal[
"Selected for Development",
"Weekly Backlog",
"In Development",
"Ready For Review",
"On Hold",
"Done",
],
) -> tuple[str, str]:
"""
Get the status field ID and option ID for the given field name in the project.
Args:
field_name (str): The name of the field to retrieve.
Must be one of the predefined statuses.
Returns:
tuple[str, str]: A tuple containing the field ID and option ID.
"""
field_id = None
option_id = None
project_fields = self.get_project_fields()
for field in project_fields:
if field["name"] != "Status":
continue
field_id = field["id"]
for option in field["options"]:
if option["name"] == field_name:
option_id = option["id"]
break
if not field_id or not option_id:
raise ValueError(f"Field '{field_name}' not found in project fields.")
return field_id, option_id
def set_field_option(self, item_id, field_id, option_id):
"""
Set the option of a project item for a single-select field.
Args:
item_id (str): The ID of the project item to update.
field_id (str): The ID of the field to update.
option_id (str): The ID of the option to set.
"""
mutation = """
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
"""
variables = {
"projectId": self.project_node_id,
"itemId": item_id,
"fieldId": field_id,
"optionId": option_id,
}
return self.run_graphql(mutation, variables)
@functools.lru_cache(maxsize=1)
def get_project_fields(self) -> list[dict]:
"""
Get the available fields in the project.
This method caches the result to avoid multiple API calls.
Returns:
list[dict]: A list of fields in the project.
"""
query = """
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
"""
variables = {"projectId": self.project_node_id}
resp = self.run_graphql(query, variables)
return list(filter(bool, resp["data"]["node"]["fields"]["nodes"]))
def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]:
"""
Get the linked issues of a pull request.
Args:
pr_number (int): The pull request number.
Returns:
list[dict]: A list of linked issues.
"""
query = """
query($number: Int!, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id
closingIssuesReferences(first: 50) {
edges {
node {
id
body
number
title
}
}
}
}
}
}
"""
variables = {
"number": pr_number,
"owner": self.gh_config.organization,
"repo": self.gh_config.repository,
}
resp = self.run_graphql(query, variables)
edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"]
return [edge["node"] for edge in edges if edge.get("node")]
def main():
# GitHub settings
token = os.getenv("TOKEN")
org = os.getenv("ORG")
repo = os.getenv("REPO")
project_number = os.getenv("PROJECT_NUMBER")
pr_number = os.getenv("PR_NUMBER")
if not token:
raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.")
if not org:
raise ValueError("GitHub organization is not set. Please set the ORG environment variable.")
if not repo:
raise ValueError("GitHub repository is not set. Please set the REPO environment variable.")
if not project_number:
raise ValueError(
"GitHub project number is not set. Please set the PROJECT_NUMBER environment variable."
)
if not pr_number:
raise ValueError(
"Pull request number is not set. Please set the PR_NUMBER environment variable."
)
project_number = int(project_number)
pr_number = int(pr_number)
gh_config = GHConfig(
token=token,
organization=org,
repository=repo,
project_number=project_number,
graphql_url="https://api.github.com/graphql",
rest_url=f"https://api.github.com/repos/{org}/{repo}/issues",
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
)
project_item_handler = ProjectItemHandler(gh_config=gh_config)
# Get PR info
pr = project_item_handler.repo.get_pull(pr_number)
# Get the linked issues of the pull request
linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number)
print(f"Linked issues: {linked_issues}")
target_status = "In Development" if pr.draft else "Ready For Review"
print(f"Target status: {target_status}")
for issue in linked_issues:
project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status)
if __name__ == "__main__":
main()

View File

@@ -1,2 +0,0 @@
pydantic
pygithub

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,80 +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
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,58 +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
- name: Conda install and run pytest
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- name: Upload logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: pytest-logs
path: ./logs/*.log
retention-days: 7

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,40 +0,0 @@
name: Sync PR to Project
on:
pull_request:
types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize]
jobs:
sync-project:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
contents: read
env:
PROJECT_NUMBER: 3 # BEC Project
ORG: 'bec-project'
REPO: 'bec_widgets'
TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Set up python environment
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Checkout repo
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Install dependencies
run: |
pip install -r ./.github/scripts/pr_issue_sync/requirements.txt
- name: Sync PR to Project
run: |
python ./.github/scripts/pr_issue_sync/pr_issue_sync.py

3
.gitignore vendored
View File

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

275
.gitlab-ci.yml Normal file
View File

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

View File

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

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

220
README.md
View File

@@ -1,200 +1,70 @@
![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)
**⚠️ Important Notice:**
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.
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
## 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[pyside6]
```
### From source (recommended for development)
For development purposes, you can clone the repository and install the package locally in editable mode:
```bash
git clone https://github.com/bec-project/bec_widgets.git
git clone https://gitlab.psi.ch/bec/bec-widgets
cd bec_widgets
pip install -e .[dev]
pip install -e .[dev,pyside6]
```
## Features
### 1. Dock area interface: build GUIs in seconds
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
operate immediately and refine later with RPC or Designer if needed.
![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_())
```
</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" />
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
specified.
## Documentation
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## Contributing
All commits should use the Angular commit scheme:
> #### <a name="commit-header"></a>Angular Commit Message Header
>
> ```
> <type>(<scope>): <short summary>
> │ │ │
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
> │ │
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
> │ elements|forms|http|language-service|localize|platform-browser|
> │ platform-browser-dynamic|platform-server|router|service-worker|
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
> │ devtools
>
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
> ```
>
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
> ##### Type
>
> Must be one of the following:
>
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
> * **docs**: Documentation only changes
> * **feat**: A new feature
> * **fix**: A bug fix
> * **perf**: A code change that improves performance
> * **refactor**: A code change that neither fixes a bug nor adds a feature
> * **test**: Adding missing tests or correcting existing tests
## License
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)

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 +1,4 @@
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

@@ -0,0 +1,199 @@
""" This module contains the GUI for the 1D alignment application.
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
"""
import os
from typing import Optional
from bec_lib.device import Signal as BECSignal
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot as Slot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
PositionerGroup,
)
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
class Alignment1D:
"""Alignment GUI to perform 1D scans"""
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
"""Initialization
Args:
config: Configuration of the application.
client: BEC client object.
gui_id: GUI ID.
"""
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
QApplication.instance().aboutToQuit.connect(self.close)
self.dev = self.client.device_manager.devices
self._accent_colors = get_accent_colors()
self.ui_file = "alignment_1d.ui"
self.ui = None
self.progress_bar = None
self.waveform = None
self.init_ui()
def init_ui(self):
"""Initialise the UI from QT Designer file"""
current_path = os.path.dirname(__file__)
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
# Customize the plotting widget
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
self._customise_bec_waveform_widget()
# Setup comboboxes for motor and signal selection
# FIXME after changing the filtering in the combobox
self._setup_signal_combobox()
# Setup motor indicator
self._setup_motor_indicator()
# Setup progress bar
self._setup_progress_bar()
# Add actions buttons
self._customise_buttons()
# Hook scaninfo updates
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
def show(self):
return self.ui.show()
##############################
############ SLOTS ###########
##############################
@Slot(dict, dict)
def scan_status_callback(self, content: dict, _) -> None:
"""This slot allows to enable/disable the UI critical components when a scan is running"""
if content["status"] in ["open"]:
self.enable_ui(False)
elif content["status"] in ["aborted", "halted", "closed"]:
self.enable_ui(True)
@Slot(tuple)
def move_to_center(self, move_request: tuple) -> None:
"""Move the selected motor to the center"""
motor = self.ui.device_combobox.currentText()
if move_request[0] in ["center", "center1", "center2"]:
pos = move_request[1]
self.dev.get(motor).move(float(pos), relative=False)
@Slot()
def reset_progress_bar(self) -> None:
"""Reset the progress bar"""
self.progress_bar.set_value(0)
self.progress_bar.set_minimum(0)
@Slot(dict, dict)
def update_progress_bar(self, content: dict, _) -> None:
"""Hook to update the progress bar
Args:
content: Content of the scan progress message.
metadata: Metadata of the message.
"""
if content["max_value"] == 0:
self.progress_bar.set_value(0)
return
self.progress_bar.set_maximum(content["max_value"])
self.progress_bar.set_value(content["value"])
@Slot()
def clear_queue(self) -> None:
"""Clear the scan queue"""
self.queue.request_queue_reset()
##############################
######## END OF SLOTS ########
##############################
def enable_ui(self, enable: bool) -> None:
"""Enable or disable the UI components"""
# Enable/disable motor and signal selection
self.ui.device_combobox_2.setEnabled(enable)
# Enable/disable DAP selection
self.ui.dap_combo_box.setEnabled(enable)
# Enable/disable Scan Button
# self.ui.scan_button.setEnabled(enable)
# Disable move to buttons in LMFitDialog
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
def _customise_buttons(self) -> None:
"""Add action buttons for the Action Control.
In addition, we are adding a callback to also clear the queue to the stop button
to ensure that upon clicking the button, no scans from another client may be queued
which would be confusing without the queue widget.
"""
fit_dialog = self.ui.findChild(LMFitDialog)
fit_dialog.active_action_list = ["center", "center1", "center2"]
fit_dialog.move_action.connect(self.move_to_center)
stop_button = self.ui.findChild(StopButton)
stop_button.button.setText("Stop and Clear Queue")
stop_button.button.clicked.connect(self.clear_queue)
def _customise_bec_waveform_widget(self) -> None:
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
self.waveform.toolbar.clear()
def _setup_motor_indicator(self) -> None:
"""Setup the arrow item"""
self.waveform.waveform.tick_item.add_to_plot()
positioner_box = self.ui.findChild(PositionerGroup)
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
self.waveform.waveform.tick_item.set_position(0)
def _setup_signal_combobox(self) -> None:
"""Setup signal selection"""
# FIXME after changing the filtering in the combobox
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
self.ui.device_combobox_2.setCurrentText(signals[0])
self.ui.device_combobox_2.set_device_filter("Signal")
def _setup_progress_bar(self) -> None:
"""Setup progress bar"""
# FIXME once the BECScanProgressBar is implemented
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
self.progress_bar.set_value(0)
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
def close(self):
logger.info("Disconnecting", repr(self.bec_dispatcher))
self.bec_dispatcher.disconnect_all()
logger.info("Shutting down BEC Client", repr(self.client))
self.client.shutdown()
def main():
import sys
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
window = Alignment1D()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,615 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>mainWindow</class>
<widget class="QMainWindow" name="mainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1611</width>
<height>1019</height>
</rect>
</property>
<property name="windowTitle">
<string>Alignment tool</string>
</property>
<widget class="QWidget" name="widget">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QWidget" name="widget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="DarkModeButton" name="dark_mode_button"/>
</item>
<item>
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BECStatusBox" name="bec_status_box">
<property name="compact_view" stdset="0">
<bool>true</bool>
</property>
<property name="label" stdset="0">
<string>BEC Servers</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BECQueue" name="bec_queue">
<property name="compact_view" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QRadioButton" name="radioButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>SLS Light On</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QRadioButton" name="radioButton_3">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>BEAMLINE Checks</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="autoExclusive">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="StopButton" name="stop_button">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>40</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>40</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="BECProgressBar" name="bec_progress_bar">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="ControlTab">
<attribute name="title">
<string>Alignment Control</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QWidget" name="widget_4" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>1</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="ScanControl" name="scan_control">
<property name="current_scan" stdset="0">
<string>line_scan</string>
</property>
<property name="hide_arg_box" stdset="0">
<bool>false</bool>
</property>
<property name="hide_scan_selection_combobox" stdset="0">
<bool>true</bool>
</property>
<property name="hide_add_remove_buttons" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="PositionerGroup" name="positioner_group"/>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="widget_3" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>4</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="widget_2" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="label_2">
<property name="font">
<font/>
</property>
<property name="text">
<string>Monitor</string>
</property>
</widget>
</item>
<item>
<widget class="DeviceComboBox" name="device_combobox_2"/>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="font">
<font/>
</property>
<property name="text">
<string>LMFit Model</string>
</property>
</widget>
</item>
<item>
<widget class="DapComboBox" name="dap_combo_box"/>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Enable ROI</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="toggle_switch">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>3</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Activate linear region select for LMFit</string>
</property>
<property name="layoutDirection">
<enum>Qt::LayoutDirection::LeftToRight</enum>
</property>
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_8">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="BECWaveformWidget" name="bec_waveform_widget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>1</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>600</width>
<height>450</height>
</size>
</property>
<property name="clear_curves_on_plot_update" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="LMFitDialog" name="lm_fit_dialog">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>190</height>
</size>
</property>
<property name="always_show_latest" stdset="0">
<bool>true</bool>
</property>
<property name="hide_curve_selection" stdset="0">
<bool>true</bool>
</property>
<property name="hide_summary" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Logbook</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="WebsiteWidget" name="website_widget">
<property name="url" stdset="0">
<string>https://scilog.psi.ch/login</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>DapComboBox</class>
<extends>QWidget</extends>
<header>dap_combo_box</header>
</customwidget>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWidget</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECProgressBar</class>
<extends>QWidget</extends>
<header>bec_progress_bar</header>
</customwidget>
<customwidget>
<class>DarkModeButton</class>
<extends>QWidget</extends>
<header>dark_mode_button</header>
</customwidget>
<customwidget>
<class>PositionerGroup</class>
<extends>QWidget</extends>
<header>positioner_group</header>
</customwidget>
<customwidget>
<class>BECWaveformWidget</class>
<extends>QWidget</extends>
<header>bec_waveform_widget</header>
</customwidget>
<customwidget>
<class>DeviceComboBox</class>
<extends>QComboBox</extends>
<header>device_combobox</header>
</customwidget>
<customwidget>
<class>LMFitDialog</class>
<extends>QWidget</extends>
<header>lm_fit_dialog</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>toggle_switch</sender>
<signal>enabled(bool)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>toogle_roi_select(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>1042</x>
<y>212</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>322</y>
</hint>
</hints>
</connection>
<connection>
<sender>bec_waveform_widget</sender>
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
<receiver>lm_fit_dialog</receiver>
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
<hints>
<hint type="sourcelabel">
<x>1099</x>
<y>258</y>
</hint>
<hint type="destinationlabel">
<x>1157</x>
<y>929</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combobox_2</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>plot(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>427</y>
</hint>
</hints>
</connection>
<connection>
<sender>device_combobox_2</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_y_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>577</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>909</x>
<y>215</y>
</hint>
</hints>
</connection>
<connection>
<sender>dap_combo_box</sender>
<signal>new_dap_config(QString,QString,QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>add_dap(QString,QString,QString)</slot>
<hints>
<hint type="sourcelabel">
<x>909</x>
<y>215</y>
</hint>
<hint type="destinationlabel">
<x>1416</x>
<y>447</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>positioner_group</receiver>
<slot>set_positioners(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>230</x>
<y>306</y>
</hint>
<hint type="destinationlabel">
<x>187</x>
<y>926</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>bec_waveform_widget</receiver>
<slot>set_x(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>187</x>
<y>356</y>
</hint>
<hint type="destinationlabel">
<x>972</x>
<y>509</y>
</hint>
</hints>
</connection>
<connection>
<sender>scan_control</sender>
<signal>device_selected(QString)</signal>
<receiver>dap_combo_box</receiver>
<slot>select_x_axis(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>187</x>
<y>356</y>
</hint>
<hint type="destinationlabel">
<x>794</x>
<y>202</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,84 @@
"""
Launcher for BEC GUI Applications
Application must be located in bec_widgets/applications ;
in order for the launcher to find the application, it has to be put in
a subdirectory with the same name as the main Python module:
/bec_widgets/applications
├── alignment
│ └── alignment_1d
│ └── alignment_1d.py
├── other_app
└── other_app.py
The tree above would contain 2 applications, alignment_1d and other_app.
The Python module for the application must have `if __name__ == "__main__":`
in order for the launcher to execute it (it is run with `python -m`).
"""
import argparse
import os
import sys
MODULE_PATH = os.path.dirname(__file__)
def find_apps(base_dir: str) -> list[str]:
matching_modules = []
for root, dirs, files in os.walk(base_dir):
parent_dir = os.path.basename(root)
for file in files:
if file.endswith(".py") and file != "__init__.py":
file_name_without_ext = os.path.splitext(file)[0]
if file_name_without_ext == parent_dir:
rel_path = os.path.relpath(root, base_dir)
module_path = rel_path.replace(os.sep, ".")
module_name = f"{module_path}.{file_name_without_ext}"
matching_modules.append((file_name_without_ext, module_name))
return matching_modules
def main():
parser = argparse.ArgumentParser(description="BEC application launcher")
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
# Add a positional argument for the module, which acts as a fallback if -m is not provided
parser.add_argument(
"positional_module",
nargs="?", # This makes the positional argument optional
help="Positional argument that is treated as module if -m is not specified.",
)
args = parser.parse_args()
# If the -m/--module is not provided, fallback to the positional argument
module = args.module if args.module else args.positional_module
if module:
for app_name, app_module in find_apps(MODULE_PATH):
if module in (app_name, app_module):
print("Starting:", app_name)
python_executable = sys.executable
# Replace the current process with the new Python module
os.execvp(
python_executable,
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
)
print(f"Error: cannot find application {module}")
# display list of apps
print("Available applications:")
for app, _ in find_apps(MODULE_PATH):
print(f" - {app}")
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,11 +1,9 @@
from __future__ import annotations
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
_dock_area = BECDockArea(object_name=object_name)
return _dock_area

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
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.QtCore import Qt, Signal
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -21,30 +21,25 @@ from qtpy.QtWidgets import (
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.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
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__)
class LaunchTile(RoundedFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal()
def __init__(
@@ -55,15 +50,9 @@ class LaunchTile(RoundedFrame):
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)
@@ -94,26 +83,12 @@ class LaunchTile(RoundedFrame):
# 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.setWordWrap(True)
self.main_label.setAlignment(Qt.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.Fixed, QSizePolicy.Fixed)
@@ -154,29 +129,6 @@ class LaunchTile(RoundedFrame):
)
self.layout.addWidget(self.action_button, alignment=Qt.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.ElideRight, max_width))
class LaunchWindow(BECMainWindow):
RPC = True
@@ -189,15 +141,12 @@ class LaunchWindow(BECMainWindow):
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 = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
@@ -207,124 +156,53 @@ class LaunchWindow(BECMainWindow):
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.register_tile(
name="dock_area",
self.tile_dock_area = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
self.tile_auto_update = LaunchTile(
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.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.register_tile(
name="custom_ui_file",
self.tile_ui_file = LaunchTile(
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,
)
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# 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()),
)
# Add tiles to the main layout
self.central_widget.layout.addWidget(self.tile_dock_area)
self.central_widget.layout.addWidget(self.tile_auto_update)
self.central_widget.layout.addWidget(self.tile_ui_file)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
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,
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.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
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
)
def launch(
self,
@@ -332,7 +210,7 @@ class LaunchWindow(BECMainWindow):
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
**kwargs,
) -> QWidget | None:
) -> QWidget:
"""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.
@@ -353,8 +231,6 @@ class LaunchWindow(BECMainWindow):
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
@@ -366,20 +242,12 @@ class LaunchWindow(BECMainWindow):
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)
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)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
@@ -395,24 +263,17 @@ class LaunchWindow(BECMainWindow):
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindowNoRPC()
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
"""
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
instantiate it directly; otherwise, embed it in a UILaunchWindow.
"""
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
# Parse the UI to detect top-level widget class
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
@@ -420,22 +281,19 @@ class LaunchWindow(BECMainWindow):
if widget is None:
raise ValueError("No widget found in the UI file.")
# Load the UI into a widget
loader = UILoader(None)
loaded = loader.loader(ui_file)
# Display the UI in a BECMainWindow
if isinstance(loaded, BECMainWindow):
window = loaded
window.object_name = filename
else:
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
window.setWindowTitle(f"BEC - {filename}")
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
@@ -453,28 +311,11 @@ class LaunchWindow(BECMainWindow):
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> 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)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles.values():
for tile in self.tiles:
tile.apply_theme(theme)
super().apply_theme(theme)
@@ -483,25 +324,14 @@ class LaunchWindow(BECMainWindow):
"""
Open the auto update window.
"""
if self.tiles["auto_update"].selector is None:
if self.tile_auto_update.selector is None:
auto_update = None
else:
auto_update = self.tiles["auto_update"].selector.currentText()
auto_update = self.tile_auto_update.selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
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])
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""
@@ -541,45 +371,6 @@ class LaunchWindow(BECMainWindow):
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.
"""
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 4
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__":
import sys

View File

@@ -1,227 +0,0 @@
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class BECMainApp(BECMainWindow):
def __init__(
self,
parent=None,
*args,
anim_duration: int = ANIMATION_DURATION,
show_examples: bool = False,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
self._show_examples = bool(show_examples)
# --- Compose central UI (sidebar + stack)
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
self.stack = QStackedWidget(self)
container = QWidget(self)
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.sidebar, 0)
layout.addWidget(self.stack, 1)
self.setCentralWidget(container)
# Mapping for view switching
self._view_index: dict[str, int] = {}
self._current_view_id: str | None = None
self.sidebar.view_selected.connect(self._on_view_selected)
self._add_views()
def _add_views(self):
self.add_section("BEC Applications", "bec_apps")
self.ads = AdvancedDockArea(
self, profile_namespace="main_workspace", auto_profile_namespace=False
)
self.ads.setObjectName("MainWorkspace")
self.device_manager = DeviceManagerView(self)
self.developer_view = DeveloperView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
)
self.add_view(
icon="display_settings",
title="Device Manager",
id="device_manager",
widget=self.device_manager,
mini_text="DM",
)
self.add_view(
icon="code_blocks",
title="IDE",
widget=self.developer_view,
id="developer_view",
exclusive=True,
)
if self._show_examples:
self.add_section("Examples", "examples")
waveform_view_popup = WaveformViewPopup(
parent=self, id="waveform_view_popup", title="Waveform Plot"
)
waveform_view_stack = WaveformViewInline(
parent=self, id="waveform_view_stack", title="Waveform Plot"
)
self.add_view(
icon="show_chart",
title="Waveform With Popup",
id="waveform_popup",
widget=waveform_view_popup,
mini_text="Popup",
)
self.add_view(
icon="show_chart",
title="Waveform InLine Stack",
id="waveform_stack",
widget=waveform_view_stack,
mini_text="Stack",
)
self.set_current("dock_area")
self.sidebar.add_dark_mode_item()
# --- Public API ------------------------------------------------------
def add_section(self, title: str, id: str, position: int | None = None):
return self.sidebar.add_section(title, id, position)
def add_separator(self):
return self.sidebar.add_separator()
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
return self.sidebar.add_dark_mode_item(id=id, position=position)
def add_view(
self,
*,
icon: str,
title: str,
id: str,
widget: QWidget,
mini_text: str | None = None,
position: int | None = None,
from_top: bool = True,
toggleable: bool = True,
exclusive: bool = True,
) -> NavigationItem:
"""
Register a view in the stack and create a matching nav item in the sidebar.
Args:
icon(str): Icon name for the nav item.
title(str): Title for the nav item.
id(str): Unique ID for the view/item.
widget(QWidget): The widget to add to the stack.
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
position(int, optional): Position to insert the nav item.
from_top(bool, optional): Whether to count position from the top or bottom.
toggleable(bool, optional): Whether the nav item is toggleable.
exclusive(bool, optional): Whether the nav item is exclusive.
Returns:
NavigationItem: The created navigation item.
"""
item = self.sidebar.add_item(
icon=icon,
title=title,
id=id,
mini_text=mini_text,
position=position,
from_top=from_top,
toggleable=toggleable,
exclusive=exclusive,
)
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
if isinstance(widget, ViewBase):
view_widget = widget
view_widget.view_id = id
view_widget.view_title = title
else:
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
idx = self.stack.addWidget(view_widget)
self._view_index[id] = idx
return item
def set_current(self, id: str) -> None:
if id in self._view_index:
self.sidebar.activate_item(id)
# Internal: route sidebar selection to the stack
def _on_view_selected(self, vid: str) -> None:
# Determine current view
current_index = self.stack.currentIndex()
current_view = (
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
)
# Ask current view whether we may leave
if current_view is not None and hasattr(current_view, "on_exit"):
may_leave = current_view.on_exit()
if may_leave is False:
# Veto: restore previous highlight without re-emitting selection
if self._current_view_id is not None:
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
return
# Proceed with switch
idx = self._view_index.get(vid)
if idx is None or not (0 <= idx < self.stack.count()):
return
self.stack.setCurrentIndex(idx)
new_view = self.stack.widget(idx)
self._current_view_id = vid
if hasattr(new_view, "on_enter"):
new_view.on_enter()
if __name__ == "__main__": # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="BEC Main Application")
parser.add_argument(
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
)
# Let Qt consume the remaining args
args, qt_args = parser.parse_known_args(sys.argv[1:])
app = QApplication([sys.argv[0], *qt_args])
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
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)
w.show()
sys.exit(app.exec())

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,372 +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,60 +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
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,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.developer_widget = DeveloperWidget(parent=self)
self.set_content(self.developer_widget)
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, 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,383 +0,0 @@
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.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
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 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):
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 = WebConsole(self)
self.console.setObjectName("Console")
self.terminal = WebConsole(self, startup_cmd="")
self.terminal.setObjectName("Terminal")
self.monaco = MonacoDock(self)
self.monaco.setObjectName("MonacoEditor")
self.monaco.save_enabled.connect(self._on_save_enabled_update)
self.plotting_ads = AdvancedDockArea(
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.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):
self.monaco.save_file()
@SafeSlot()
def on_save_as(self):
self.monaco.save_file(force_save_as=True)
@SafeSlot()
def on_vim_triggered(self):
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
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):
if not self.current_script_id:
return
self.console.send_ctrl_c()
@property
def current_script_id(self):
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value: str | 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(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):
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,341 +0,0 @@
"""Dialogs for device configuration forms and ophyd testing."""
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"
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)
# Connect signal for validation messages
# Load and apply configuration
config = config or {}
self.device_manager_ophyd_test.change_device_configs([config], True, True)
# 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)
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_layout = QtWidgets.QHBoxLayout()
for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn):
btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
btn_layout.addWidget(btn)
btn_box = QtWidgets.QGroupBox("Actions")
btn_box.setLayout(btn_layout)
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)
@SafeSlot(int)
def _finished(self, state: int):
for widget in self._control_widgets.values():
widget.close()
widget.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 _add_config(self):
config = self._device_config_template.get_config_fields()
config_status = ConfigStatus.UNKNOWN.value
connection_status = ConnectionStatus.UNKNOWN.value
validation_msg = ""
try:
if DeviceModel.model_validate(config) == DeviceModel.model_validate(
self._validation_result[0]
):
config_status = self._validation_result[1]
connection_status = self._validation_result[2]
validation_msg = self._validation_result[3]
except Exception:
logger.debug(
f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses."
)
if not validate_name(config.get("name", "")):
msg_box = self._create_warning_message_box(
"Invalid Device Name",
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ",
)
msg_box.exec()
return
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{validation_msg}",
)
msg_box.exec()
return
self.accepted_data.emit(
config, config_status, connection_status, validation_msg, self._old_device_name
)
self.accept()
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
# self._device_config_template.set_config_fields(self.config_validation_result[0])
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,720 +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, Dict, 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 import OphydValidation
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
get_validation_icons,
)
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
if TYPE_CHECKING:
from bec_widgets.utils.colors import AccentColor
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.Rejected
OK = QtWidgets.QDialog.Accepted
# Signal to trigger upload after confirmation
upload_confirmed = QtCore.Signal(int)
def __init__(
self,
parent,
ophyd_test_widget: OphydValidation,
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.ophyd_test_widget = ophyd_test_widget
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._current_progress: int | None = None
self._setup_ui()
self._update_ui()
# Disable validation features if no ophyd test widget provided, else connect validation
self._validation_connection = self.ophyd_test_widget.validation_completed.connect(
self._update_from_ophyd_device_tests
)
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 accept(self):
self.cleanup()
return super().accept()
def reject(self):
self.cleanup()
return super().reject()
def cleanup(self):
"""Cleanup on dialog finish."""
self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection)
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)
# Progress bar
self._progress_bar = BECProgressBar(self)
self._progress_bar.setVisible(False)
button_layout.addWidget(self._progress_bar)
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."""
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.validate_connections_btn.setEnabled(False)
self._progress_bar.setVisible(True)
self._progress_bar.maximum = len(testable_devices)
self._progress_bar.minimum = 0
self._progress_bar.set_value(0)
self._current_progress = 0
self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True)
@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.",
]
)
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(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
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)
if self._current_progress is not None:
self._current_progress += 1
self._progress_bar.set_value(self._current_progress)
if self._current_progress >= self._progress_bar.maximum:
self._progress_bar.setVisible(False)
self._progress_bar.set_value(0)
self._current_progress = None
self.validation_completed()
self._update_ui()
return
# Update UI sections
self.config_section.add_device(device_config, config_status, connection_status)
# Recalculate summaries and UI state
self._update_ui()
def validation_completed(self):
"""Called when connection validation is completed."""
self.validate_connections_btn.setEnabled(True)
self._update_ui()
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")
from unittest import mock
ophyd_test_widget = mock.MagicMock(spec=OphydValidation)
dialog = UploadRedisDialog(
parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget
)
dialog.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -1,665 +0,0 @@
from __future__ import annotations
import os
from functools import partial
from typing import List, Literal, get_args
import yaml
from bec_lib import config_helper
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from qtpy.QtCore import QMetaObject, QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, 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.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.advanced_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 (
ConnectionStatus,
)
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
logger = bec_logger.logger
_yes_no_question = partial(
QMessageBox.question,
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
defaultButton=QMessageBox.StandardButton.No,
)
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, client=None, *args, **kwargs):
super().__init__(parent=parent, variant="compact", *args, **kwargs)
# Push to Redis dialog
self._upload_redis_dialog: UploadRedisDialog | None = None
self._dialog_validation_connection: QMetaObject.Connection | None = None
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.change_device_configs,),
),
(
self.device_table_view.device_config_in_sync_with_redis,
(self._update_config_enabled_button,),
),
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
]:
for slot in slots:
signal.connect(slot)
# Add toolbar
self._add_toolbar()
# Build dock layout using shared helpers
self._build_docks()
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)
@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()
self.request_ophyd_validation.emit(configs, True, connect)
def _update_config_enabled_button(self, enabled: bool):
action = self.toolbar.components.get_action("update_config_redis")
action.action.setEnabled(not enabled)
if enabled:
action.action.setToolTip("Push current config to BEC Server")
else:
action.action.setToolTip("Current config is in sync with BEC Server, button disabled.")
@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.set_busy(enabled=True, text="Flushing configuration in BEC Server...")
self.client.config.reset_config()
logger.info("Successfully flushed configuration in BEC Server.")
self.set_busy(enabled=False)
# Check if config is in sync, enable load redis button
self.device_table_view.device_config_in_sync_with_redis.emit(
self.device_table_view._is_config_in_sync_with_redis()
)
validation_results = self.device_table_view.get_validation_results()
for config, config_status, connnection_status in validation_results.values():
if connnection_status == ConnectionStatus.CONNECTED.value:
self.device_table_view.update_device_validation(
config, config_status, ConnectionStatus.CAN_CONNECT, ""
)
@SafeSlot()
def _load_redis_action(self):
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
if self.client.device_manager is None:
logger.error("No device manager connected, cannot load config from BEC Server.")
return
if not self.device_table_view.get_device_config():
# If no config is composed yet, load directly
self.device_table_view.set_device_config(
self.client.device_manager._get_redis_device_config()
)
return
reply = _yes_no_question(
self,
"Load currently active config in BEC Server",
"Do you really want to discard the current config and reload?",
)
if reply == QMessageBox.StandardButton.Yes:
self.device_table_view.set_device_config(
self.client.device_manager._get_redis_device_config()
)
@SafeSlot()
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
"""Action to push the current composition to Redis using the upload dialog."""
# Check if validations are still running
if self.ophyd_test_view.running_ophyd_tests is True:
return QMessageBox.warning(
self, "Validation in Progress", "Please wait for the validation to finish."
)
# Get all device configurations with their validation status
validation_results = self.device_table_view.get_validation_results()
# Create and show upload dialog
self._upload_redis_dialog = UploadRedisDialog(
parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view
)
# 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()
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(enabled=True, text="Uploading configuration to BEC Server...")
def _handle_push_complete_to_communicator(self):
"""Handle completion of the config push to Redis."""
self.set_busy(enabled=False)
self._update_validation_icons_after_upload()
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(enabled=False)
self._update_validation_icons_after_upload()
def _update_validation_icons_after_upload(self):
"""Update validation icons after uploading config to Redis."""
if self.client.device_manager is None:
return
device_names_in_session = list(self.client.device_manager.devices.keys())
validation_results = self.device_table_view.get_validation_results()
devices_to_update = []
for config, config_status, connection_status in validation_results.values():
if config["name"] in device_names_in_session:
devices_to_update.append(
(config, config_status, ConnectionStatus.CONNECTED.value, "")
)
self.device_table_view.update_multiple_device_validations(devices_to_update)
@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.device_table_view.update_device_configs([data])
@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 = "",
):
self.device_table_view.add_device_configs([data])
@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,73 +0,0 @@
"""Module for Device Manager View."""
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
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,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.device_manager_widget = DeviceManagerWidget(parent=self)
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()
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",
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):
super().__init__(parent=parent, client=client)
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,363 +0,0 @@
from __future__ import annotations
from typing import List
from qtpy.QtCore import QEventLoop, Qt, QTimer
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSplitter,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.waveform import Waveform
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class ViewBase(QWidget):
"""Wrapper for a content widget used inside the main app's stacked view.
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
Args:
content (QWidget): The actual view widget to display.
parent (QWidget | None): Parent widget.
id (str | None): Optional view id, useful for debugging or introspection.
title (str | None): Optional human-readable title.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent)
self.content: QWidget | None = None
self.view_id = id
self.view_title = title
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
if content is not None:
self.set_content(content)
def set_content(self, content: QWidget) -> None:
"""Replace the current content widget with a new one."""
if self.content is not None:
self.content.setParent(None)
self.content = content
self.layout().addWidget(content)
@SafeSlot()
def on_enter(self) -> None:
"""Called after the view becomes current/visible.
Default implementation does nothing. Override in subclasses.
"""
pass
@SafeSlot()
def on_exit(self) -> bool:
"""Called before the view is switched away/hidden.
Return True to allow switching, or False to veto.
Default implementation allows switching.
"""
return True
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
####################################################################################################
# Example views for demonstration/testing purposes
####################################################################################################
# --- Popup UI version ---
class WaveformViewPopup(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.waveform = Waveform(parent=self)
self.set_content(self.waveform)
@SafeSlot()
def on_enter(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("Configure Waveform View")
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
# same as in the CurveRow used in waveform
self.device_edit = DeviceComboBox(parent=self)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
form = QFormLayout()
form.addRow(label)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
v = QVBoxLayout(dialog)
v.addLayout(form)
v.addWidget(buttons)
if dialog.exec_() == QDialog.Accepted:
self.waveform.plot(
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
)
@SafeSlot()
def on_exit(self) -> bool:
ans = QMessageBox.question(
self,
"Switch and clear?",
"Do you want to switch views and clear the plot?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans == QMessageBox.Yes:
self.waveform.clear_all()
return True
return False
# --- Inline stacked UI version ---
class WaveformViewInline(ViewBase): # pragma: no cover
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# Root layout for this view uses a stacked layout
self.stack = QStackedLayout()
container = QWidget(self)
container.setLayout(self.stack)
self.set_content(container)
# --- Page 0: Settings page (inline form)
self.settings_page = QWidget()
sp_layout = QVBoxLayout(self.settings_page)
sp_layout.setContentsMargins(16, 16, 16, 16)
sp_layout.setSpacing(12)
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
self.device_edit = DeviceComboBox(parent=self.settings_page)
self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.device_edit.setCurrentIndex(0)
self.entry_edit = SignalComboBox(parent=self.settings_page)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
form = QFormLayout()
form.addRow(title)
form.addRow("Device", self.device_edit)
form.addRow("Signal", self.entry_edit)
btn_row = QHBoxLayout()
ok_btn = QPushButton("OK", parent=self.settings_page)
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
btn_row.addStretch(1)
btn_row.addWidget(cancel_btn)
btn_row.addWidget(ok_btn)
sp_layout.addLayout(form)
sp_layout.addLayout(btn_row)
# --- Page 1: Waveform page
self.waveform_page = QWidget()
wf_layout = QVBoxLayout(self.waveform_page)
wf_layout.setContentsMargins(0, 0, 0, 0)
self.waveform = Waveform(parent=self.waveform_page)
wf_layout.addWidget(self.waveform)
# --- Page 2: Exit confirmation page (inline)
self.confirm_page = QWidget()
cp_layout = QVBoxLayout(self.confirm_page)
cp_layout.setContentsMargins(16, 16, 16, 16)
cp_layout.setSpacing(12)
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
cp_buttons = QHBoxLayout()
no_btn = QPushButton("No", parent=self.confirm_page)
yes_btn = QPushButton("Yes", parent=self.confirm_page)
cp_buttons.addStretch(1)
cp_buttons.addWidget(no_btn)
cp_buttons.addWidget(yes_btn)
cp_layout.addWidget(qlabel)
cp_layout.addLayout(cp_buttons)
# Add pages to the stack
self.stack.addWidget(self.settings_page) # index 0
self.stack.addWidget(self.waveform_page) # index 1
self.stack.addWidget(self.confirm_page) # index 2
# Wire settings buttons
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
cancel_btn.clicked.connect(self._show_waveform_without_changes)
# Prepare result holder for the inline confirmation
self._exit_choice_yes = None
yes_btn.clicked.connect(lambda: self._exit_reply(True))
no_btn.clicked.connect(lambda: self._exit_reply(False))
@SafeSlot()
def on_enter(self) -> None:
# Always start on the settings page when entering
self.stack.setCurrentIndex(0)
@SafeSlot()
def on_exit(self) -> bool:
# Show inline confirmation page and synchronously wait for a choice
# -> trick to make the choice blocking, however popup would be cleaner solution
self._exit_choice_yes = None
self.stack.setCurrentIndex(2)
loop = QEventLoop()
self._exit_loop = loop
loop.exec_()
if self._exit_choice_yes:
self.waveform.clear_all()
return True
# Revert to waveform view if user cancelled switching
self.stack.setCurrentIndex(1)
return False
def _apply_settings_and_show_waveform(self):
dev = self.device_edit.currentText()
sig = self.entry_edit.currentText()
if dev and sig:
self.waveform.plot(y_name=dev, y_entry=sig)
self.stack.setCurrentIndex(1)
def _show_waveform_without_changes(self):
# Just show waveform page without plotting
self.stack.setCurrentIndex(1)
def _exit_reply(self, yes: bool):
self._exit_choice_yes = bool(yes)
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
self._exit_loop.quit()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -14,29 +14,24 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
logger = bec_logger.logger
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
str | bool | dict,
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
]
# pylint: disable=redefined-outer-scope
@@ -54,7 +49,7 @@ def _filter_output(output: str) -> str:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)
@@ -154,10 +149,8 @@ def wait_for_server(client: BECGuiClient):
raise RuntimeError("GUI is not alive")
try:
if client._gui_started_event.wait(timeout=timeout):
if client._gui_started_timer is not None:
# cancel the timer, we are done
client._gui_started_timer.cancel()
client._gui_started_timer.join()
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
@@ -221,7 +214,6 @@ class BECGuiClient(RPCBase):
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
####################
#### Client API ####
@@ -266,44 +258,23 @@ class BECGuiClient(RPCBase):
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
return self._start(wait=wait)
def show(self, wait=True) -> None:
"""
Show the GUI window.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
def show(self):
"""Show the GUI window."""
if self._check_if_server_is_alive():
return self._show_all()
return self._start(wait=wait)
return self.start(wait=True)
def hide(self):
"""Hide the GUI window."""
return self._hide_all()
def raise_window(self, wait: bool = True) -> None:
"""
Bring GUI windows to the front.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive():
return self._raise_all()
return self._start(wait=wait)
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
launch_script: str = "dock_area",
**kwargs,
) -> client.BECDockArea:
"""Create a new top-level dock area.
@@ -319,11 +290,11 @@ class BECGuiClient(RPCBase):
if wait:
with wait_for_server(self):
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
"launch", "dock_area", name, geometry
) # pylint: disable=protected-access
return widget
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget
@@ -392,8 +363,7 @@ class BECGuiClient(RPCBase):
timeout = 60
# Wait for 'bec' gui to be registered, this may take some time
# After 60s timeout. Should this raise an exception on timeout?
start = time.monotonic()
while time.monotonic() < start + timeout:
while time.time() < time.time() + timeout:
if len(list(self._server_registry.keys())) < 2 or not hasattr(
self, self._anchor_widget
):
@@ -407,9 +377,6 @@ class BECGuiClient(RPCBase):
"""
Start the GUI server, and execute callback when it is launched
"""
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
@@ -456,8 +423,8 @@ class BECGuiClient(RPCBase):
self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self):
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("show") # pylint: disable=protected-access
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -467,24 +434,11 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
if self._killed:
return
self.launcher._run_rpc("hide")
for window in self._top_level.values():
window.hide()
def _do_raise_all(self):
"""Bring GUI windows to the front."""
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("raise") # pylint: disable=protected-access
for window in self._top_level.values():
window._run_rpc("raise") # type: ignore[attr-defined]
def _raise_all(self):
with wait_for_server(self):
if self._killed:
return
return self._do_raise_all()
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
if not self._killed:
for window in self._top_level.values():
window.hide()
def _update_dynamic_namespace(self, server_registry: dict):
"""
@@ -565,7 +519,7 @@ if __name__ == "__main__": # pragma: no cover
# Test the client_utils.py module
gui = BECGuiClient()
gui.show(wait=True)
gui.start(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:

View File

@@ -40,8 +40,6 @@ class ClientGenerator:
"""import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base
@@ -53,7 +51,7 @@ from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -111,7 +109,7 @@ _Widgets = {
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
@@ -180,10 +178,7 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
@@ -208,26 +203,14 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ...
"""
self.content += f"""
{self._rpc_call(timeout)}"""
self.content += """
@rpc_call"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.
@@ -238,18 +221,18 @@ class {class_name}(RPCBase):"""
# Combine header and content, then format with black
full_content = self.header + "\n" + self.content
try:
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
except black.NothingChanged:
formatted_content = full_content
config = isort.Config(
isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=False,
include_trailing_comma=True,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content, config=config)
formatted_content = isort.code(formatted_content)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@@ -332,7 +315,4 @@ def main():
if __name__ == "__main__": # pragma: no cover
import sys
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
main()

View File

@@ -7,7 +7,6 @@ from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.device import DeviceBaseWithConfig
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
@@ -25,43 +24,6 @@ else:
# pylint: disable=protected-access
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
# if dev.<device> is passed to GUI, it passes full_name
if hasattr(arg, "full_name"):
return arg.full_name
elif hasattr(arg, "name"):
return arg.name
return arg
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.
@@ -85,7 +47,15 @@ def rpc_call(func):
return None # func(*args, **kwargs)
caller_frame = caller_frame.f_back
args, kwargs = _transform_args_kwargs(args, kwargs)
out = []
for arg in args:
if hasattr(arg, "name"):
arg = arg.name
out.append(arg)
args = tuple(out)
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
@@ -160,7 +130,6 @@ class RPCBase:
config: dict | None = None,
object_name: str | None = None,
parent=None,
**kwargs,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
@@ -175,21 +144,21 @@ class RPCBase:
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.object_name}>"
return f"<{qualname} with name: {self.widget_name}>"
def remove(self):
"""
Remove the widget.
"""
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
if proxy := obj.get("container_proxy"):
assert isinstance(proxy, str)
self._run_rpc("remove", gui_id=proxy)
return
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self.object_name
@property
def _root(self) -> BECGuiClient:
"""
@@ -202,20 +171,7 @@ class RPCBase:
parent = parent._parent
return parent # type: ignore
def raise_window(self):
"""Bring this widget (or its container) to the front."""
# Use explicit call to ensure action name is 'raise' (not 'raise_')
return self._run_rpc("raise")
def _run_rpc(
self,
method,
*args,
wait_for_rpc_response=True,
timeout=5,
gui_id: str | None = None,
**kwargs,
) -> Any:
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=5, **kwargs) -> Any:
"""
Run the RPC call.
@@ -223,23 +179,15 @@ class RPCBase:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
if method in ["show", "hide", "raise"] and gui_id is None:
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
raise ValueError(f"Widget {self._gui_id} not found.")
gui_id = obj.get("container_proxy") # type: ignore
request_id = str(uuid.uuid4())
rpc_msg = messages.GUIInstructionMessage(
action=method,
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access

View File

@@ -31,9 +31,10 @@ class RPCWidgetHandler:
Returns:
None
"""
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
clss = get_custom_classes("bec_widgets")
self._widget_classes = get_all_plugin_widgets() | {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""

View File

@@ -6,12 +6,10 @@ import os
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
import darkdetect
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_qthemes import apply_theme
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
@@ -30,6 +28,7 @@ class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
self.encoding = "utf8"
def write(self, buffer):
self._buffer.append(buffer)
@@ -40,10 +39,6 @@ class SimpleFileLikeFromLogOutputFunc:
self._log_func(lines)
self._buffer = [remaining]
@property
def encoding(self):
return "utf-8"
def close(self):
return
@@ -89,16 +84,34 @@ class GUIServer:
service_config = ServiceConfig()
return service_config
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.
"""
self.launcher_window = cast(LaunchWindow, self.launcher_window)
remaining_connections = [
connection
for connection in connections.values()
if connection.parent_id != self.launcher_window.gui_id
]
if len(remaining_connections) <= 1:
self.launcher_window.show()
self.launcher_window.activateWindow()
self.launcher_window.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True)
else:
self.launcher_window.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False)
def _run(self):
"""
Run the GUI server.
"""
self.app = QApplication(sys.argv)
if darkdetect.isDark():
apply_theme("dark")
else:
apply_theme("light")
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.setup_bec_icon()
@@ -113,6 +126,10 @@ class GUIServer:
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
register = RPCRegister()
register.callbacks.append(self._turn_off_the_lights)
register.broadcast()
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.
@@ -150,8 +167,6 @@ class GUIServer:
"""
Shutdown the GUI server.
"""
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()

View File

@@ -0,0 +1,92 @@
import os
import sys
from qtpy.QtCore import QSize
from qtpy.QtGui import QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.ui_loader import UILoader
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECGeneralApp(QMainWindow):
def __init__(self, parent=None):
super(BECGeneralApp, self).__init__(parent)
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
self.load_ui(ui_file_path)
self.resize(1280, 720)
self.ini_ui()
def ini_ui(self):
self._setup_icons()
self._hook_menubar_docs()
self._hook_theme_bar()
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def _hook_menubar_docs(self):
# BEC Docs
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
# BEC Widgets Docs
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
# Bug report
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
def change_theme(self, theme):
apply_theme(theme)
def _setup_icons(self):
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
computer_icon = QIcon.fromTheme("computer")
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
self.ui.action_BEC_docs.setIcon(help_icon)
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
self.ui.action_bug_report.setIcon(bug_icon)
self.ui.central_tab.setTabIcon(0, widget_icon)
self.ui.central_tab.setTabIcon(1, computer_icon)
def _hook_theme_bar(self):
self.ui.action_light.setCheckable(True)
self.ui.action_dark.setCheckable(True)
# Create an action group to make sure only one can be checked at a time
theme_group = QActionGroup(self)
theme_group.addAction(self.ui.action_light)
theme_group.addAction(self.ui.action_dark)
theme_group.setExclusive(True)
# Connect the actions to the theme change method
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
self.ui.action_dark.trigger()
def main(): # pragma: no cover
app = QApplication(sys.argv)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
main_window = BECGeneralApp()
main_window.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,262 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>1139</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTabWidget" name="central_tab">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="dock_area_tab">
<attribute name="title">
<string>Dock Area</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="vscode_tab">
<attribute name="icon">
<iconset theme="QIcon::ThemeIcon::Computer"/>
</attribute>
<attribute name="title">
<string>Visual Studio Code</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="VSCodeEditor" name="vscode"/>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>31</height>
</rect>
</property>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="action_BEC_docs"/>
<addaction name="action_BEC_widgets_docs"/>
<addaction name="action_bug_report"/>
</widget>
<widget class="QMenu" name="menuTheme">
<property name="title">
<string>Theme</string>
</property>
<addaction name="action_light"/>
<addaction name="action_dark"/>
</widget>
<addaction name="menuTheme"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="dock_scan_control">
<property name="windowTitle">
<string>Scan Control</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="ScanControl" name="scan_control"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_status_2">
<property name="windowTitle">
<string>BEC Service Status</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_3">
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECStatusBox" name="bec_status_box_2"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_queue">
<property name="windowTitle">
<string>Scan Queue</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_4">
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECQueue" name="bec_queue">
<row/>
<column/>
<column/>
<column/>
<item row="0" column="0"/>
<item row="0" column="1"/>
<item row="0" column="2"/>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="action_BEC_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Docs</string>
</property>
</action>
<action name="action_BEC_widgets_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Widgets Docs</string>
</property>
</action>
<action name="action_bug_report">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogError"/>
</property>
<property name="text">
<string>Bug Report</string>
</property>
</action>
<action name="action_light">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Light</string>
</property>
</action>
<action name="action_dark">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Dark</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWebEngineView</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QTableWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>VSCodeEditor</class>
<extends>WebsiteWidget</extends>
<header>vs_code_editor</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>QWebEngineView</class>
<extends></extends>
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,15 @@
import webbrowser
class BECWebLinksMixin:
@staticmethod
def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")

View File

@@ -1,23 +1,12 @@
from __future__ import annotations
import ast
import importlib
import os
from typing import Any, Dict
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSplitter,
QTabWidget,
@@ -25,359 +14,148 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access.
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
Features:
- Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`.
- Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`.
- Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut.
- Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block.
"""
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self._widgets_by_name: Dict[str, QWidget] = {}
self._init_ui()
# expose helper API and basics in the inprocess console
# console push
if self.console.inprocess is True:
# A thin API wrapper so users have a stable, minimal surface in the console
class _ConsoleAPI:
def __init__(self, win: "JupyterConsoleWindow"):
self._win = win
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None):
"""Add an existing QWidget as a new tab and expose it in the console under `shortcut`."""
return self._win.add_widget(widget, shortcut, title=title)
def add_widget_by_class_path(
self,
class_path: str,
shortcut: str,
kwargs: dict | None = None,
title: str | None = None,
):
"""Import a QWidget class from `class_path`, instantiate it, and add it."""
return self._win.add_widget_by_class_path(
class_path, shortcut, kwargs=kwargs, title=title
)
def add_widget_by_type(
self,
widget_type: str,
shortcut: str,
kwargs: dict | None = None,
title: str | None = None,
):
"""Instantiate a registered BEC widget by type string and add it."""
return self._win.add_widget_by_type(
widget_type, shortcut, kwargs=kwargs, title=title
)
def list_widgets(self):
return list(self._win._widgets_by_name.keys())
def get_widget(self, shortcut: str) -> QWidget | None:
return self._win._widgets_by_name.get(shortcut)
def available_widgets(self):
return list(widget_handler.widget_classes.keys())
self.jc = _ConsoleAPI(self)
self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh})
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"pg": pg,
"wh": wh,
"dock": self.dock,
"im": self.im,
"mi": self.mi,
"mm": self.mm,
"lm": self.lm,
"btn1": self.btn1,
"btn2": self.btn2,
"btn3": self.btn3,
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wf": self.wf,
"scatter": self.scatter,
"scatter_mi": self.scatter,
"mwf": self.mwf,
}
)
def _init_ui(self):
self.layout = QHBoxLayout(self)
# Horizontal splitter: left = widgets tabs, right = console + add-widget panel
# Horizontal splitter
splitter = QSplitter(self)
self.layout.addWidget(splitter)
# Left: tabs that will host dynamically added widgets
self.tab_widget = QTabWidget(splitter)
tab_widget = QTabWidget(splitter)
# Right: console area with an add-widget mini panel on top
right_panel = QGroupBox("Jupyter Console", splitter)
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(6, 12, 6, 6)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
# Add-widget mini panel
add_panel = QFrame(right_panel)
shape = QFrame.Shape.StyledPanel # PySide6 style enums
add_panel.setFrameShape(shape)
add_grid = QGridLayout(add_panel)
add_grid.setContentsMargins(8, 8, 8, 8)
add_grid.setHorizontalSpacing(8)
add_grid.setVerticalSpacing(6)
third_tab = QWidget()
third_tab_layout = QVBoxLayout(third_tab)
self.lm = LayoutManagerWidget()
third_tab_layout.addWidget(self.lm)
tab_widget.addTab(third_tab, "Layout Manager Widget")
instr = QLabel(
"Add a widget by class path or choose a registered BEC widget type,"
" and expose it in the console under a shortcut.\n"
"Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform"
)
instr.setWordWrap(True)
add_grid.addWidget(instr, 0, 0, 1, 2)
fourth_tab = QWidget()
fourth_tab_layout = QVBoxLayout(fourth_tab)
self.pb = PlotBase()
self.pi = self.pb.plot_item
fourth_tab_layout.addWidget(self.pb)
tab_widget.addTab(fourth_tab, "PlotBase")
# Registered widget selector
reg_label = QLabel("Registered")
reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.registry_combo = QComboBox(add_panel)
self.registry_combo.setEditable(False)
self.refresh_btn = QPushButton("Refresh")
reg_row = QHBoxLayout()
reg_row.addWidget(self.registry_combo)
reg_row.addWidget(self.refresh_btn)
add_grid.addWidget(reg_label, 1, 0)
add_grid.addLayout(reg_row, 1, 1)
tab_widget.setCurrentIndex(3)
# Class path entry
class_label = QLabel("Class")
class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.class_path_edit = QLineEdit(add_panel)
self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)")
add_grid.addWidget(class_label, 2, 0)
add_grid.addWidget(self.class_path_edit, 2, 1)
# Shortcut
shortcut_label = QLabel("Shortcut")
shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.shortcut_edit = QLineEdit(add_panel)
self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)")
add_grid.addWidget(shortcut_label, 3, 0)
add_grid.addWidget(self.shortcut_edit, 3, 1)
# Kwargs
kwargs_label = QLabel("Kwargs")
kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.kwargs_edit = QLineEdit(add_panel)
self.kwargs_edit.setPlaceholderText(
'Optional kwargs as dict literal, e.g. {"popups": True}'
)
add_grid.addWidget(kwargs_label, 4, 0)
add_grid.addWidget(self.kwargs_edit, 4, 1)
# Title
title_label = QLabel("Title")
title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.title_edit = QLineEdit(add_panel)
self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)")
add_grid.addWidget(title_label, 5, 0)
add_grid.addWidget(self.title_edit, 5, 1)
# Buttons
btn_row = QHBoxLayout()
self.add_btn = QPushButton("Add by class path")
self.add_btn.clicked.connect(self._on_add_widget_clicked)
self.add_reg_btn = QPushButton("Add registered")
self.add_reg_btn.clicked.connect(self._on_add_registered_clicked)
btn_row.addStretch(1)
btn_row.addWidget(self.add_reg_btn)
btn_row.addWidget(self.add_btn)
add_grid.addLayout(btn_row, 6, 0, 1, 2)
# Make the second column expand
add_grid.setColumnStretch(0, 0)
add_grid.setColumnStretch(1, 1)
# Console widget
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
# Vertical splitter between add panel and console
right_splitter = QSplitter(Qt.Vertical, right_panel)
right_splitter.addWidget(add_panel)
right_splitter.addWidget(self.console)
right_splitter.setStretchFactor(0, 0)
right_splitter.setStretchFactor(1, 1)
right_splitter.setSizes([300, 600])
# Some buttons for layout testing
self.btn1 = QPushButton("Button 1")
self.btn2 = QPushButton("Button 2")
self.btn3 = QPushButton("Button 3")
self.btn4 = QPushButton("Button 4")
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
# Put splitter into the right group box
right_layout.addWidget(right_splitter)
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wf = Waveform()
fifth_tab_layout.addWidget(self.wf)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
tab_widget.setCurrentIndex(4)
# Populate registry on startup
self._populate_registry_widgets()
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image()
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(5)
def _populate_registry_widgets(self):
try:
widget_handler.update_available_widgets()
items = sorted(widget_handler.widget_classes.keys())
except Exception as exc:
print(f"Failed to load registered widgets: {exc}")
items = []
self.registry_combo.clear()
self.registry_combo.addItems(items)
seventh_tab = QWidget()
seventh_tab_layout = QVBoxLayout(seventh_tab)
self.scatter = ScatterWaveform()
self.scatter_mi = self.scatter.main_curve
self.scatter.plot("samx", "samy", "bpm4i")
seventh_tab_layout.addWidget(self.scatter)
tab_widget.addTab(seventh_tab, "Scatter Waveform")
tab_widget.setCurrentIndex(6)
def _on_add_widget_clicked(self):
class_path = self.class_path_edit.text().strip()
shortcut = self.shortcut_edit.text().strip()
kwargs_text = self.kwargs_edit.text().strip()
title = self.title_edit.text().strip() or None
eighth_tab = QWidget()
eighth_tab_layout = QVBoxLayout(eighth_tab)
self.mm = MotorMap()
eighth_tab_layout.addWidget(self.mm)
tab_widget.addTab(eighth_tab, "Motor Map")
tab_widget.setCurrentIndex(7)
if not class_path or not shortcut:
print("Please provide both class path and shortcut.")
return
ninth_tab = QWidget()
ninth_tab_layout = QVBoxLayout(ninth_tab)
self.mwf = MultiWaveform()
ninth_tab_layout.addWidget(self.mwf)
tab_widget.addTab(ninth_tab, "MultiWaveform")
tab_widget.setCurrentIndex(8)
kwargs: dict | None = None
if kwargs_text:
try:
parsed = ast.literal_eval(kwargs_text)
if isinstance(parsed, dict):
kwargs = parsed
else:
print("Kwargs must be a Python dict literal, ignoring input.")
except Exception as exc:
print(f"Failed to parse kwargs: {exc}")
# add stuff to the new Waveform widget
self._init_waveform()
try:
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
except Exception as exc:
print(f"Failed to instantiate {class_path}: {exc}")
return
self.setWindowTitle("Jupyter Console Window")
try:
self.add_widget(widget, shortcut, title=title)
except Exception as exc:
print(f"Failed to add widget: {exc}")
return
# focus the newly added tab
idx = self.tab_widget.count() - 1
if idx >= 0:
self.tab_widget.setCurrentIndex(idx)
def _on_add_registered_clicked(self):
widget_type = self.registry_combo.currentText().strip()
shortcut = self.shortcut_edit.text().strip()
kwargs_text = self.kwargs_edit.text().strip()
title = self.title_edit.text().strip() or None
if not widget_type or not shortcut:
print("Please select a registered widget and provide a shortcut.")
return
kwargs: dict | None = None
if kwargs_text:
try:
parsed = ast.literal_eval(kwargs_text)
if isinstance(parsed, dict):
kwargs = parsed
else:
print("Kwargs must be a Python dict literal, ignoring input.")
except Exception as exc:
print(f"Failed to parse kwargs: {exc}")
try:
self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title)
except Exception as exc:
print(f"Failed to add registered widget: {exc}")
return
# focus the newly added tab
idx = self.tab_widget.count() - 1
if idx >= 0:
self.tab_widget.setCurrentIndex(idx)
def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget:
module_path, _, class_name = class_path.rpartition(".")
if not module_path or not class_name:
raise ValueError("class_path must be of the form 'package.module.Class'")
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
if kwargs is None:
obj = cls()
else:
obj = cls(**kwargs)
if not isinstance(obj, QWidget):
raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}")
return obj
def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget:
"""Add a QWidget as a new tab and expose it in the Jupyter console.
- widget: a QWidget instance to host in a new tab
- shortcut: variable name used in the console to access it
- title: optional tab title (defaults to shortcut or class name)
"""
if not isinstance(widget, QWidget):
raise TypeError("widget must be a QWidget instance")
if not shortcut or not shortcut.isidentifier():
raise ValueError("shortcut must be a valid Python identifier")
if shortcut in self._widgets_by_name:
raise ValueError(f"A widget with shortcut '{shortcut}' already exists")
if self.console.inprocess is not True:
raise RuntimeError("Adding widgets and exposing them requires inprocess console")
tab_title = title or shortcut or widget.__class__.__name__
self.tab_widget.addTab(widget, tab_title)
self._widgets_by_name[shortcut] = widget
# Expose in console under the given shortcut
self._push_to_console({shortcut: widget})
return widget
def add_widget_by_class_path(
self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
) -> QWidget:
widget = self._instantiate_from_class_path(class_path, kwargs=kwargs)
return self.add_widget(widget, shortcut, title=title)
def add_widget_by_type(
self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None
) -> QWidget:
"""Instantiate a registered BEC widget by its type string and add it as a tab.
If kwargs does not contain `object_name`, it will default to the provided shortcut.
"""
# Ensure registry is loaded
widget_handler.update_available_widgets()
cls = widget_handler.widget_classes.get(widget_type)
if cls is None:
raise ValueError(f"Unknown registered widget type: {widget_type}")
if kwargs is None:
kwargs = {"object_name": shortcut}
else:
kwargs = dict(kwargs)
kwargs.setdefault("object_name", shortcut)
# Instantiate and add
widget = cls(**kwargs)
if not isinstance(widget, QWidget):
raise TypeError(
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
)
return self.add_widget(widget, shortcut, title=title)
def _push_to_console(self, mapping: Dict[str, Any]):
"""Push Python objects into the inprocess kernel user namespace."""
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(mapping)
else:
raise RuntimeError("Can only push variables when using inprocess kernel")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
# clean up any widgets that might have custom cleanup
try:
# call cleanup on known containers if present
dock = self._widgets_by_name.get("dock")
if isinstance(dock, BECDockArea):
dock.cleanup()
dock.close()
except Exception:
pass
# Ensure the embedded kernel and BEC client are shut down before window teardown
self.console.shutdown_kernel()
self.dock.cleanup()
self.dock.close()
self.console.close()
super().closeEvent(event)
@@ -391,26 +169,18 @@ if __name__ == "__main__": # pragma: no cover
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
apply_theme("dark")
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
win = JupyterConsoleWindow()
# Examples: add two widgets programmatically to demonstrate usage
try:
win.add_widget_by_type("Waveform", shortcut="wf")
except Exception as exc:
print(f"Example add failed (Waveform by type): {exc}")
try:
win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True})
except Exception as exc:
print(f"Example add failed (Image by type): {exc}")
win.show()
win.resize(1500, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
"readoutPriority": "baseline",
"deviceClass": "ophyd.Device",
"deviceConfig": {},
"deviceTags": {"user device"},
"deviceTags": ["user device"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
@@ -89,28 +89,16 @@ class FakePositioner(BECPositioner):
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
"deviceTags": {"user motors"},
"deviceTags": ["user motors"],
"enabled": enabled,
"readOnly": False,
"name": self.name,
}
self._info = {
"signals": {
"readback": {
"kind_str": "hinted",
"component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
"readback": {"kind_str": "5"}, # hinted
"setpoint": {"kind_str": "1"}, # normal
"velocity": {"kind_str": "2"}, # config
}
}
self.signals = {
@@ -173,7 +161,7 @@ class FakePositioner(BECPositioner):
def set_read_value(self, value):
self.read_value = value
def read(self, cached=False):
def read(self):
return self.signals
def set_limits(self, limits):
@@ -196,8 +184,8 @@ class FakePositioner(BECPositioner):
class Positioner(FakePositioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
def __init__(self, name="test", limits=None, read_value=1.0):
super().__init__(name, limits, read_value)
class Device(FakeDevice):
@@ -212,49 +200,10 @@ class DMMock:
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
def add_devices(self, devices: list):
"""
Add devices to the DeviceContainer.
Args:
devices (list): List of device instances to add.
"""
def add_devives(self, devices: list):
for device in devices:
self.devices[device.name] = device
def get_bec_signals(self, signal_class_name: str):
"""
Emulate DeviceManager.get_bec_signals for unit-tests.
For “AsyncSignal” we list every device whose readout_priority is
ReadoutPriority.ASYNC and build a minimal tuple
(device_name, signal_name, signal_info_dict) that matches the real
API shape used by Waveform._check_async_signal_found.
"""
signals: list[tuple[str, str, dict]] = []
if signal_class_name != "AsyncSignal":
return signals
for device in self.devices.values():
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
device_name = device.name
signal_name = device.name # primary signal in our mocks
signal_info = {
"component_name": signal_name,
"obj_name": signal_name,
"kind_str": "hinted",
"signal_class": signal_class_name,
"metadata": {
"connected": True,
"precision": None,
"read_access": True,
"timestamp": 0.0,
"write_access": True,
},
}
signals.append((device_name, signal_name, signal_info))
return signals
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import os
import time
import traceback
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Optional
@@ -15,7 +14,8 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
@@ -77,8 +77,6 @@ class BECConnector:
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
widget_removed = Signal()
name_established = Signal(str)
def __init__(
self,
@@ -87,21 +85,9 @@ class BECConnector:
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
parent_id: str | None = None,
**kwargs,
):
"""
BECConnector mixin class to handle BEC client and device manager.
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration with specific gui id.
gui_id(str, optional): The GUI ID.
object_name(str, optional): The object name.
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
**kwargs:
"""
# Extract object_name from kwargs to not pass it to Qt class
object_name = object_name or kwargs.pop("objectName", None)
# Ensure the parent is always the first argument for QObject
@@ -113,9 +99,6 @@ class BECConnector:
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# flag to check if the object was destroyed and its cleanup was called
self._destroyed = False
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
@@ -125,21 +108,10 @@ class BECConnector:
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
# the function depends on BECClient, and BECDispatcher
@SafeSlot()
@pyqtSlot()
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
logger.info("Disconnecting", repr(dispatcher))
dispatcher.disconnect_all()
try: # shutdown ophyd threads if any
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
logger.info("Ophyd dispatcher shut down successfully.")
except Exception as e:
logger.warning(
f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}"
)
logger.info("Shutting down BEC Client", repr(client))
client.shutdown()
@@ -156,6 +128,7 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
self.parent_id = parent_id
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
@@ -174,6 +147,10 @@ class BECConnector:
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if parent_id is None:
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
if connector_parent is not None:
self.parent_id = connector_parent.gui_id
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -182,30 +159,6 @@ class BECConnector:
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
self.root_widget = root_widget
QTimer.singleShot(0, self._update_object_name)
@property
def parent_id(self) -> str | None:
try:
if self.root_widget:
return None
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
return connector_parent.gui_id if connector_parent else None
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
@@ -217,10 +170,6 @@ class BECConnector:
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
try:
self.name_established.emit(self.object_name)
except RuntimeError:
return
def _enforce_unique_sibling_name(self):
"""
@@ -230,7 +179,7 @@ class BECConnector:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.sendPostedEvents()
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
@@ -279,7 +228,7 @@ class BECConnector:
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
@@ -407,7 +356,7 @@ class BECConnector:
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
# @SafeSlot(str)
# @pyqtSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
@@ -439,7 +388,7 @@ class BECConnector:
self.client = client
self.get_bec_shortcuts()
@SafeSlot(ConnectionConfig) # TODO can be also dict
@pyqtSlot(ConnectionConfig) # TODO can be also dict
def on_config_update(self, config: ConnectionConfig | dict) -> None:
"""
Update the configuration for the widget.
@@ -467,7 +416,6 @@ class BECConnector:
# i.e. Curve Item from Waveform
else:
self.rpc_register.remove_rpc(self)
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""

View File

@@ -16,9 +16,9 @@ if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
@@ -78,7 +78,7 @@ def list_editable_packages() -> set[str]:
return editable_packages
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
def patch_designer(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
@@ -119,7 +119,7 @@ def patch_designer(cmd_args: list[str] = []): # pragma: no cover
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def find_plugin_paths(base_path: Path):
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
# Patch the designer function
def open_designer(cmd_args: list[str] = []): # pragma: no cover
def main(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
@@ -160,11 +160,7 @@ def open_designer(cmd_args: list[str] = []): # pragma: no cover
set_plugin_environment_variable(plugin_paths)
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
patch_designer()
if __name__ == "__main__": # pragma: no cover

View File

@@ -4,9 +4,8 @@ import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
from typing import TYPE_CHECKING, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
@@ -15,52 +14,30 @@ from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils.serialization import register_serializer_extension
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.rpc_server import RPCServer
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
cb_signal = pyqtSignal(dict, dict)
def __init__(self, cb: Callable, cb_info: dict | None = None):
"""
Initialize the QtThreadSafeCallback.
Args:
cb (Callable): The callback function to be wrapped.
cb_info (dict, optional): Additional information about the callback. Defaults to None.
"""
def __init__(self, cb):
super().__init__()
self.cb_info = cb_info
self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
return id(self.cb)
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -107,7 +84,7 @@ class BECDispatcher:
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str | None = None,
gui_id: str = None,
*args,
**kwargs,
):
@@ -120,9 +97,7 @@ class BECDispatcher:
if self._initialized:
return
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self._slots = collections.defaultdict(set)
self.client = client
if self.client is None:
@@ -145,8 +120,6 @@ class BECDispatcher:
except redis.exceptions.ConnectionError:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
register_serializer_extension()
logger.success("Initialized BECDispatcher")
self.start_cli_server(gui_id=gui_id)
@@ -163,8 +136,7 @@ class BECDispatcher:
def connect_slot(
self,
slot: Callable,
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
cb_info: dict | None = None,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
**kwargs,
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
@@ -172,40 +144,34 @@ class BECDispatcher:
Args:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
self._slots[slot].update(set(topics_str))
def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
):
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
Disconnect a slot from a topic.
Args:
slot(Callable): The slot to disconnect
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
topics(Union[str, list]): The topic(s) to disconnect from
"""
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
for connected_slot in self._registered_slots.values():
for connected_slot in self._slots:
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics:
del self._registered_slots[connected_slot]
self._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
@@ -216,16 +182,11 @@ class BECDispatcher:
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
remove_slots = []
for connected_slot in self._registered_slots.values():
connected_slot.topics.difference_update(set(topics_str))
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
def disconnect_all(self, *args, **kwargs):
"""

View File

@@ -1,93 +0,0 @@
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
logger = bec_logger.logger
class BECList(QListWidget):
"""List Widget that manages ListWidgetItems with associated widgets."""
def __init__(self, parent=None):
super().__init__(parent)
self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {}
def __contains__(self, key: str) -> bool:
return key in self._widget_map
def add_widget_item(self, key: str, widget: QWidget):
"""
Add a widget to the list, mapping is associated with the given key.
Args:
key (str): Key to associate with the widget.
widget (QWidget): Widget to add to the list.
"""
if key in self._widget_map:
self.remove_widget_item(key)
item = QListWidgetItem()
item.setSizeHint(widget.sizeHint())
self.insertItem(0, item)
self.setItemWidget(item, widget)
self._widget_map[key] = (item, widget)
def remove_widget_item(self, key: str):
"""
Remove a widget by identifier key.
Args:
key (str): Key associated with the widget to remove.
"""
if key not in self._widget_map:
return
item, widget = self._widget_map.pop(key)
row = self.row(item)
self.takeItem(row)
try:
widget.close()
except Exception:
logger.debug(f"Could not close widget properly for key: {key}.")
try:
widget.deleteLater()
except Exception:
logger.debug(f"Could not delete widget properly for key: {key}.")
def clear_widgets(self):
"""Remove and destroy all widget items."""
for key in list(self._widget_map.keys()):
self.remove_widget_item(key)
self._widget_map.clear()
self.clear()
def get_widget(self, key: str) -> QWidget | None:
"""Return the widget for a given key."""
entry = self._widget_map.get(key)
return entry[1] if entry else None
def get_item(self, key: str) -> QListWidgetItem | None:
"""Return the QListWidgetItem for a given key."""
entry = self._widget_map.get(key)
return entry[0] if entry else None
def get_widgets(self) -> list[QWidget]:
"""Return all managed widgets."""
return [w for _, w in self._widget_map.values()]
def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None:
"""Return the widget associated with a given QListWidgetItem."""
for itm, widget in self._widget_map.values():
if itm == item:
return widget
return None
def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None:
"""Return the QListWidgetItem associated with a given widget."""
for itm, w in self._widget_map.values():
if w == widget:
return itm
return None
def get_all_keys(self) -> list[str]:
"""Return all keys for managed widgets."""
return list(self._widget_map.keys())

View File

@@ -3,17 +3,12 @@ from __future__ import annotations
import importlib.metadata
import inspect
import pkgutil
import traceback
from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_lib.logger import bec_logger
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
logger = bec_logger.logger
from bec_widgets.utils.bec_widget import BECWidget
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
@@ -26,7 +21,7 @@ def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
def _loaded_submodules_from_specs(
submodule_specs: tuple[ModuleSpec | None, ...],
submodule_specs: tuple[ModuleSpec | None, ...]
) -> Generator[ModuleType, None, None]:
"""Load all submodules from the given specs."""
for submodule in (
@@ -35,14 +30,7 @@ def _loaded_submodules_from_specs(
assert isinstance(
submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!"
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
exception_text = "".join(traceback.format_exception(e))
if "(most likely due to a circular import)" in exception_text:
logger.warning(f"Circular import encountered while loading {submodule}")
else:
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
submodule.__loader__.exec_module(submodule)
yield submodule
@@ -53,30 +41,27 @@ def _submodule_by_name(module: ModuleType, name: str):
return None
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
"""Find any BECWidget subclasses in the given module and return them with their info."""
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their names."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
classes = inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget
and not item.__module__.startswith("bec_widgets"),
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
for k, v in classes
return dict(
inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
)
def _all_widgets_from_all_submods(module) -> BECClassContainer:
def _all_widgets_from_all_submods(module):
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"):
return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets += _all_widgets_from_all_submods(submod)
widgets.update(_all_widgets_from_all_submods(submod))
return widgets
@@ -90,16 +75,15 @@ def get_plugin_client_module() -> ModuleType | None:
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> BECClassContainer:
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin)
else:
return BECClassContainer()
return {}
if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module()
print(get_all_plugin_widgets())
...

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
will allow you to decide by yourself when to unblock and execute the callback again."""

View File

@@ -1,20 +1,15 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
import shiboken6
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.colors import set_theme
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
@@ -28,7 +23,7 @@ class BECWidget(BECConnector):
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
USER_ACCESS = ["remove", "attach", "detach"]
USER_ACCESS = ["remove"]
# pylint: disable=too-many-arguments
def __init__(
@@ -37,9 +32,8 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
start_busy: bool = False,
busy_text: str = "Loading…",
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
parent_id: str | None = None,
**kwargs,
):
"""
@@ -48,7 +42,8 @@ class BECWidget(BECConnector):
>>> class MyWidget(BECWidget, QWidget):
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
>>> super().__init__(client=client, config=config, gui_id=gui_id)
>>> QWidget.__init__(self, parent=parent)
Args:
@@ -58,37 +53,36 @@ class BECWidget(BECConnector):
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
super().__init__(
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
client=client,
config=config,
gui_id=gui_id,
parent_dock=parent_dock,
parent_id=parent_id,
**kwargs,
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
# Instead, we will set the theme to the system setting on startup
if darkdetect.isDark():
set_theme("dark")
else:
set_theme("light")
if theme_update:
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
# Initialize optional busy loader overlay utility (lazy by default)
self._busy_overlay = None
self._loading = False
if start_busy and isinstance(self, QWidget):
try:
overlay = self._ensure_busy_overlay(busy_text=busy_text)
if overlay is not None:
overlay.setGeometry(self.rect())
overlay.raise_()
overlay.show()
self._loading = True
except Exception as exc:
logger.debug(f"Busy loader init skipped: {exc}")
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
@SafeSlot(str)
@SafeSlot()
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -97,78 +91,9 @@ class BECWidget(BECConnector):
theme = qapp.theme.theme
else:
theme = "dark"
self._update_overlay_theme(theme)
self.apply_theme(theme)
def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"):
"""Create the busy overlay on demand and cache it in _busy_overlay.
Returns the overlay instance or None if not a QWidget.
"""
if not isinstance(self, QWidget):
return None
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
from bec_widgets.utils.busy_loader import install_busy_loader
overlay = install_busy_loader(self, text=busy_text, start_loading=False)
self._busy_overlay = overlay
return overlay
def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None:
"""Create and attach the loading overlay to this widget if QWidget is present."""
if not isinstance(self, QWidget):
return
self._ensure_busy_overlay(busy_text=busy_text)
if start_busy and self._busy_overlay is not None:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
def set_busy(self, enabled: bool, text: str | None = None) -> None:
"""
Enable/disable the loading overlay. Optionally update the text.
Args:
enabled(bool): Whether to enable the loading overlay.
text(str, optional): The text to display on the overlay. If None, the text is not changed.
"""
if not isinstance(self, QWidget):
return
if getattr(self, "_busy_overlay", None) is None:
self._ensure_busy_overlay(busy_text=text or "Loading…")
if text is not None:
self.set_busy_text(text)
if enabled:
self._busy_overlay.setGeometry(self.rect())
self._busy_overlay.raise_()
self._busy_overlay.show()
else:
self._busy_overlay.hide()
self._loading = bool(enabled)
def is_busy(self) -> bool:
"""
Check if the loading overlay is enabled.
Returns:
bool: True if the loading overlay is enabled, False otherwise.
"""
return bool(getattr(self, "_loading", False))
def set_busy_text(self, text: str) -> None:
"""
Update the text on the loading overlay.
Args:
text(str): The text to display on the overlay.
"""
overlay = getattr(self, "_busy_overlay", None)
if overlay is None:
overlay = self._ensure_busy_overlay(busy_text=text)
if overlay is not None:
overlay.set_text(text)
@SafeSlot(str)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
@@ -177,146 +102,16 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
def _update_overlay_theme(self, theme: str):
try:
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and hasattr(overlay, "update_palette"):
overlay.update_palette()
except Exception:
logger.warning(f"Failed to apply theme {theme} to {self}")
def get_help_md(self) -> str:
"""
Method to override in subclasses to provide help text in markdown format.
Returns:
str: The help text in markdown format.
"""
return ""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def screenshot_bytes(
self,
*,
max_width: int | None = None,
max_height: int | None = None,
fmt: str = "PNG",
quality: int = -1,
) -> QByteArray:
"""
Grab this widget, optionally scale to a max size, and return encoded image bytes.
If max_width/max_height are omitted (the default), capture at full resolution.
Args:
max_width(int, optional): Maximum width of the screenshot.
max_height(int, optional): Maximum height of the screenshot.
fmt(str, optional): Image format (e.g., "PNG", "JPEG").
quality(int, optional): Image quality (0-100), -1 for default.
Returns:
QByteArray: The screenshot image bytes.
"""
if not isinstance(self, QWidget):
return QByteArray()
if not hasattr(self, "grab"):
raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}")
pixmap: QPixmap = self.grab()
if pixmap.isNull():
return QByteArray()
if max_width is not None or max_height is not None:
w = max_width if max_width is not None else pixmap.width()
h = max_height if max_height is not None else pixmap.height()
pixmap = pixmap.scaled(
w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation
)
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
pixmap.save(buf, fmt, quality)
buf.close()
return ba
def attach(self):
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
return
if not dock.isFloating():
return
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
return
if dock.isFloating():
return
dock.setFloating()
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
# Tear down busy overlay explicitly to stop spinner and remove filters
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and shiboken6.isValid(overlay):
try:
overlay.hide()
filt = getattr(overlay, "_filter", None)
if filt is not None and shiboken6.isValid(filt):
try:
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
overlay.deleteLater()
except Exception as exc:
logger.warning(f"Failed to delete busy overlay: {exc}")
self._busy_overlay = None
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
self.cleanup()
finally:
super().closeEvent(event) # pylint: disable=no-member

View File

@@ -1,253 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
class _OverlayEventFilter(QObject):
"""Keeps the overlay sized and stacked over its target widget."""
def __init__(self, target: QWidget, overlay: QWidget):
super().__init__(target)
self._target = target
self._overlay = overlay
def eventFilter(self, obj, event):
if obj is self._target and event.type() in (
QEvent.Resize,
QEvent.Show,
QEvent.LayoutRequest,
QEvent.Move,
):
self._overlay.setGeometry(self._target.rect())
self._overlay.raise_()
return False
class BusyLoaderOverlay(QWidget):
"""
A semi-transparent scrim with centered text and an animated spinner.
Call show()/hide() directly, or use via `install_busy_loader(...)`.
Args:
parent(QWidget): The parent widget to overlay.
text(str): Initial text to display.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setAutoFillBackground(False)
self.setAttribute(Qt.WA_TranslucentBackground, True)
self._opacity = opacity
self._label = QLabel(text, self)
self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = QFont(self._label.font())
f.setBold(True)
f.setPointSize(f.pointSize() + 1)
self._label.setFont(f)
self._spinner = SpinnerWidget(self)
self._spinner.setFixedSize(42, 42)
lay = QVBoxLayout(self)
lay.setContentsMargins(24, 24, 24, 24)
lay.setSpacing(10)
lay.addStretch(1)
lay.addWidget(self._spinner, 0, Qt.AlignHCenter)
lay.addWidget(self._label, 0, Qt.AlignHCenter)
lay.addStretch(1)
self._frame = QFrame(self)
self._frame.setObjectName("busyFrame")
self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self._frame.lower()
# Defaults
self._scrim_color = QColor(0, 0, 0, 110)
self._label_color = QColor(240, 240, 240)
self.update_palette()
# Start hidden; interactions beneath are blocked while visible
self.hide()
# --- API ---
def set_text(self, text: str):
"""
Update the overlay text.
Args:
text(str): The text to display on the overlay.
"""
self._label.setText(text)
def set_opacity(self, opacity: float):
"""
Set overlay opacity (0..1).
Args:
opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque).
"""
self._opacity = max(0.0, min(1.0, float(opacity)))
# Re-apply alpha using the current theme color
if isinstance(self._scrim_color, QColor):
base = QColor(self._scrim_color)
base.setAlpha(int(255 * self._opacity))
self._scrim_color = base
self.update()
def update_palette(self):
"""
Update colors from the current application theme.
"""
app = QApplication.instance()
if hasattr(app, "theme"):
theme = app.theme # type: ignore[attr-defined]
self._bg = theme.color("BORDER")
self._fg = theme.color("FG")
self._primary = theme.color("PRIMARY")
else:
# Fallback neutrals
self._bg = QColor(30, 30, 30)
self._fg = QColor(230, 230, 230)
# Semi-transparent scrim derived from bg
self._scrim_color = QColor(self._bg)
self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35)))))
self._spinner.update()
fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg)
self._label.setStyleSheet(f"color: {fg_hex};")
self._frame.setStyleSheet(
f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}"
)
self.update()
# --- QWidget overrides ---
def showEvent(self, e):
self._spinner.start()
super().showEvent(e)
def hideEvent(self, e):
self._spinner.stop()
super().hideEvent(e)
def resizeEvent(self, e):
super().resizeEvent(e)
r = self.rect().adjusted(10, 10, -10, -10)
self._frame.setGeometry(r)
def paintEvent(self, e):
super().paintEvent(e)
def install_busy_loader(
target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35
) -> BusyLoaderOverlay:
"""
Attach a BusyLoaderOverlay to `target` and keep it sized and stacked.
Args:
target(QWidget): The widget to overlay.
text(str): Initial text to display.
start_loading(bool): If True, show the overlay immediately.
opacity(float): Overlay opacity (0..1).
Returns:
BusyLoaderOverlay: The overlay instance.
"""
overlay = BusyLoaderOverlay(target, text=text, opacity=opacity)
overlay.setGeometry(target.rect())
filt = _OverlayEventFilter(target, overlay)
overlay._filter = filt # type: ignore[attr-defined]
target.installEventFilter(filt)
if start_loading:
overlay.show()
return overlay
# --------------------------
# Launchable demo
# --------------------------
class DemoWidget(BECWidget, QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(
parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…"
)
self._title = QLabel("Demo Content", self)
self._title.setAlignment(Qt.AlignCenter)
self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken)
lay = QVBoxLayout(self)
lay.addWidget(self._title)
waveform = Waveform(self)
waveform.plot([1, 2, 3, 4, 5])
lay.addWidget(waveform, 1)
QTimer.singleShot(5000, self._ready)
def _ready(self):
self._title.setText("Ready ✓")
self.set_busy(False)
class DemoWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Busy Loader — BECWidget demo")
left = DemoWidget()
right = DemoWidget()
btn_on = QPushButton("Right → Loading")
btn_off = QPushButton("Right → Ready")
btn_text = QPushButton("Set custom text")
btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…"))
btn_off.clicked.connect(lambda: right.set_busy(False))
btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…"))
panel = QWidget()
prow = QVBoxLayout(panel)
prow.addWidget(btn_on)
prow.addWidget(btn_off)
prow.addWidget(btn_text)
prow.addStretch(1)
central = QWidget()
row = QHBoxLayout(central)
row.setContentsMargins(12, 12, 12, 12)
row.setSpacing(12)
row.addWidget(left, 1)
row.addWidget(right, 1)
row.addWidget(panel, 0)
self.setCentralWidget(central)
self.resize(900, 420)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
apply_theme("light")
w = DemoWindow()
w.show()
sys.exit(app.exec())

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
from qtpy.QtCore import Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import QLabel
class ClickableLabel(QLabel):
clicked = Signal()
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.clicked.emit()
return super().mouseReleaseEvent(ev)

View File

@@ -1,55 +1,137 @@
from __future__ import annotations
import re
from typing import Literal
from typing import TYPE_CHECKING, Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes import apply_theme as apply_theme_global
from bec_qthemes._theme import AccentColors
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
else:
return QApplication.instance().theme.theme
if TYPE_CHECKING:
from bec_qthemes._main import AccentColors
def get_theme_palette():
# FIXME this is legacy code, should be removed in the future
app = QApplication.instance()
palette = app.palette()
return palette
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_accent_colors() -> AccentColors:
def get_accent_colors() -> AccentColors | None:
"""
Get the accent colors for the current theme. These colors are extensions of the color palette
and are used to highlight specific elements in the UI.
"""
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
accent_colors = AccentColors()
return accent_colors
return None
return QApplication.instance().theme.accent_colors
def process_all_deferred_deletes(qapp):
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
qapp.processEvents(QEventLoop.AllEvents)
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
if theme != "auto":
return
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
"""
process_all_deferred_deletes(QApplication.instance())
apply_theme_global(theme)
process_all_deferred_deletes(QApplication.instance())
app = QApplication.instance()
graphic_layouts = [
child
for top in app.topLevelWidgets()
for child in top.findChildren(pg.GraphicsLayoutWidget)
]
plot_items = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.PlotItem)
]
histograms = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.HistogramLUTItem)
]
# Update background color based on the theme
if theme == "light":
background_color = "#e9ecef" # Subtle contrast for light mode
foreground_color = "#141414"
label_color = "#000000"
axis_color = "#666666"
else:
background_color = "#141414" # Dark mode
foreground_color = "#e9ecef"
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# update GraphicsLayoutWidget
pg.setConfigOptions(foreground=foreground_color, background=background_color)
for pg_widget in graphic_layouts:
pg_widget.setBackground(background_color)
# update PlotItems
for plot_item in plot_items:
for axis in ["left", "right", "top", "bottom"]:
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
# Change title color
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
# Change legend color
if hasattr(plot_item, "legend") and plot_item.legend is not None:
plot_item.legend.setLabelTextColor(label_color)
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
for sample, label in plot_item.legend.items:
label_text = label.text
label.setText(label_text, color=label_color)
# update HistogramLUTItem
for histogram in histograms:
histogram.axis.setPen(pg.mkPen(color=axis_color))
histogram.axis.setTextPen(pg.mkPen(color=label_color))
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
class Colors:

View File

@@ -11,7 +11,6 @@ from qtpy.QtWidgets import (
QPushButton,
QSizePolicy,
QSpacerItem,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -123,14 +122,15 @@ class CompactPopupWidget(QWidget):
self.compact_view_widget = QWidget(self)
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
QHBoxLayout(self.compact_view_widget)
self.compact_view_widget.layout().setSpacing(5)
self.compact_view_widget.layout().setSpacing(0)
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
self.compact_view_widget.layout().addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
)
self.compact_label = QLabel(self.compact_view_widget)
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QToolButton(self.compact_view_widget)
self.compact_show_popup = QPushButton(self.compact_view_widget)
self.compact_show_popup.setFlat(True)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)
@@ -144,7 +144,6 @@ class CompactPopupWidget(QWidget):
self.container.setVisible(True)
layout(self.container)
self.layout = self.container.layout()
self._compact_view = False
self.compact_show_popup.clicked.connect(self.show_popup)
@@ -211,7 +210,7 @@ class CompactPopupWidget(QWidget):
@Property(bool)
def compact_view(self):
return self._compact_view
return self.compact_label.isVisible()
@compact_view.setter
def compact_view(self, set_compact: bool):
@@ -221,7 +220,6 @@ class CompactPopupWidget(QWidget):
the full view is displayed. This is handled by toggling visibility of
the container widget or the compact view widget.
"""
self._compact_view = set_compact
if set_compact:
self.compact_view_widget.setVisible(True)
self.container.setVisible(False)
@@ -261,3 +259,10 @@ class CompactPopupWidget(QWidget):
@expand_popup.setter
def expand_popup(self, popup: bool):
self._expand_popup = popup
def closeEvent(self, event):
# Called by Qt, on closing - since the children widgets can be
# BECWidgets, it is good to explicitely call 'close' on them,
# to ensure proper resources cleanup
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
child.close()

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from typing import Any, Type
import itertools
from typing import Literal, Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.cli.rpc.rpc_register import RPCRegister
class WidgetContainerUtils:
@@ -72,36 +73,3 @@ class WidgetContainerUtils:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
@staticmethod
def name_is_protected(name: str, container: Any = None) -> bool:
"""
Check if the name is not protected.
Args:
name(str): The name to be checked.
Returns:
bool: True if the name is not protected, False otherwise.
"""
if container is None:
container = BECGuiClient
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
return name in gui_client_methods
@staticmethod
def raise_for_invalid_name(name: str, container: Any = None) -> None:
"""
Check if the name is valid. If not, raise a ValueError.
Args:
name(str): The name to be checked.
Raises:
ValueError: If the name is not valid.
"""
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
if WidgetContainerUtils.name_is_protected(name, container):
raise ValueError(f"Name '{name}' is protected. Please choose another name.")

View File

@@ -5,13 +5,9 @@ from typing import Any
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, QPointF, Qt, Signal
from qtpy.QtGui import QCursor, QTransform
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.image.image_item import ImageItem
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
@@ -38,21 +34,13 @@ class Crosshair(QObject):
coordinatesChanged2D = Signal(tuple)
coordinatesClicked2D = Signal(tuple)
def __init__(
self,
plot_item: pg.PlotItem,
precision: int | None = None,
*,
min_precision: int = 2,
parent=None,
):
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
"""
Crosshair for 1D and 2D plots.
Args:
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
parent (QObject, optional): Parent object for the QObject. Defaults to None.
"""
super().__init__(parent)
@@ -60,9 +48,7 @@ class Crosshair(QObject):
self.is_log_x = None
self.is_derivative = None
self.plot_item = plot_item
self._precision = precision
self._min_precision = max(0, int(min_precision)) # ensure nonnegative
self.precision = precision
self.v_line = pg.InfiniteLine(angle=90, movable=False)
self.v_line.skip_auto_range = True
self.h_line = pg.InfiniteLine(angle=0, movable=False)
@@ -99,64 +85,13 @@ class Crosshair(QObject):
self.items = []
self.marker_moved_1d = {}
self.marker_clicked_1d = {}
self.marker_2d_row = None
self.marker_2d_col = None
self.marker_2d = None
self.update_markers()
self.check_log()
self.check_derivatives()
self._connect_to_theme_change()
@property
def precision(self) -> int | None:
"""Fixed number of decimals; ``None`` enables dynamic mode."""
return self._precision
@precision.setter
def precision(self, value: int | None):
"""
Set the fixed number of decimals to display.
Args:
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
"""
self._precision = value
@property
def min_precision(self) -> int:
"""Lower bound on decimals when dynamic precision is used."""
return self._min_precision
@min_precision.setter
def min_precision(self, value: int):
"""
Set the lower bound on decimals when dynamic precision is used.
Args:
value(int): The minimum number of decimals to display. Must be non-negative.
"""
self._min_precision = max(0, int(value))
def _current_precision(self) -> int:
"""
Get the current precision based on the view range or fixed precision.
"""
if self._precision is not None:
return self._precision
# Dynamically choose precision from the smaller visible span
view_range = self.plot_item.vb.viewRange()
x_span = abs(view_range[0][1] - view_range[0][0])
y_span = abs(view_range[1][1] - view_range[1][0])
# Ignore zero spans that can appear during initialisation
spans = [s for s in (x_span, y_span) if s > 0]
span = min(spans) if spans else 1.0
exponent = np.floor(np.log10(span)) # order of magnitude
decimals = max(0, int(-exponent) + 1)
return max(self._min_precision, decimals)
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@@ -164,7 +99,7 @@ class Crosshair(QObject):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@SafeSlot(str)
@Slot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -191,7 +126,7 @@ class Crosshair(QObject):
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@SafeSlot(int)
@Slot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
@@ -209,11 +144,8 @@ class Crosshair(QObject):
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
# Focus on the highlighted curve only
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
# Handle visible items in the plot item
self.items = self.plot_item.visible_items()
else: # Non PlotBase case
# Handle all items
else:
# Handle all curves
self.items = self.plot_item.items
# Create or update markers
@@ -263,55 +195,13 @@ class Crosshair(QObject):
marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d_row is not None and self.marker_2d_col is not None:
if self.marker_2d is not None:
continue
# Create horizontal ROI for row highlighting
if item.image is None:
continue
self.marker_2d_row = pg.ROI(
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_row.skip_auto_range = True
if item.image_transform is not None:
self.marker_2d_row.setTransform(item.image_transform)
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
if item.image_transform is not None:
self.marker_2d_col.setTransform(item.image_transform)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
@SafeSlot()
def update_markers_on_image_change(self):
"""
Update markers when the image changes, e.g. when the
image shape or transformation changes.
"""
for item in self.items:
if not isinstance(item, pg.ImageItem):
continue
if self.marker_2d_row is not None:
self.marker_2d_row.setSize([item.image.shape[0], 1])
self.marker_2d_row.setTransform(item.image_transform)
if self.marker_2d_col is not None:
self.marker_2d_col.setSize([1, item.image.shape[1]])
self.marker_2d_col.setTransform(item.image_transform)
# Get the current mouse position
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d)
def snap_to_data(
self, x: float, y: float
@@ -351,29 +241,11 @@ class Crosshair(QObject):
y_values[name] = closest_y
x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.objectName() or str(id(item))
name = item.config.monitor or str(id(item))
image_2d = item.image
if image_2d is None:
continue
# Map scene coordinates (plot units) back to image pixel coordinates
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
xy_trans = inv_transform.map(QPointF(x, y))
else:
xy_trans = QPointF(x, y)
# Define valid pixel coordinate bounds
min_x_px, min_y_px = 0, 0
max_x_px = image_2d.shape[0] - 1 # columns
max_y_px = image_2d.shape[1] - 1 # rows
# Clip the mapped coordinates to the image bounds
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
# Store snapped pixel positions
x_values[name] = px
y_values[name] = py
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
@@ -413,74 +285,56 @@ class Crosshair(QObject):
return list_x[original_index], list_y[original_index]
@SafeSlot(object, tuple)
def mouse_moved(self, event=None, manual_pos=None):
"""
Handles the mouse moved event, updating the crosshair position and emitting signals.
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
Args:
event(object): The mouse moved event, which contains the scene position.
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
event: The mouse moved event
"""
# Determine target (x, y) in *plot* coordinates
if manual_pos is not None:
x, y = manual_pos
else:
if event is None:
return # nothing to do
scene_pos = event[0] # SignalProxy bundle
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
return
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
x, y = view_pos.x(), view_pos.y()
# Update crosshair visuals
self.v_line.setPos(x)
self.h_line.setPos(y)
pos = event[0]
self.update_markers()
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
x, y = mouse_point.x(), mouse_point.y()
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
if snap_x_vals is None or snap_y_vals is None:
return
if all(v is None for v in snap_x_vals.values()) or all(
v is None for v in snap_y_vals.values()
):
return
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
sx, sy = snap_x_vals[name], snap_y_vals[name]
if sx is None or sy is None:
continue
self.marker_moved_1d[name].setData([sx], [sy])
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
self.coordinatesChanged1D.emit(
(name, round(sx_s, precision), round(sy_s, precision))
)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
px, py = snap_x_vals[name], snap_y_vals[name]
if px is None or py is None:
continue
# Respect image transforms
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(px, py, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
self.marker_2d_row.setPos([0, py])
self.marker_2d_col.setPos([px, 0])
self.coordinatesChanged2D.emit((name, px, py))
continue
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -510,7 +364,6 @@ class Crosshair(QObject):
# not sure how we got here, but just to be safe...
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
@@ -522,44 +375,21 @@ class Crosshair(QObject):
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
round(x_snapped_scaled, self.precision),
round(y_snapped_scaled, self.precision),
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
"""
Maps the given x and y coordinates to the transformed position using the provided transform.
Args:
x (float): The x-coordinate to transform.
y (float): The y-coordinate to transform.
transform (QTransform): The transformation to apply.
"""
origin = transform.map(QPointF(0, 0))
row = transform.map(QPointF(0, y)) - origin
col = transform.map(QPointF(x, 0)) - origin
return row, col
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
@@ -594,27 +424,14 @@ class Crosshair(QObject):
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
precision = self._current_precision()
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
if image is None:
continue
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
pt = inv_transform.map(QPointF(x, y))
px, py = pt.x(), pt.y()
else:
px, py = x, y
# Clip to valid pixel indices
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}"
text += f"\nIntensity: {intensity:.{self.precision}g}"
break
# Update coordinate label
self.coord_label.setText(text)
@@ -632,19 +449,12 @@ class Crosshair(QObject):
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
@SafeSlot()
def reset(self):
"""Resets the crosshair to its initial state."""
if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.clear_markers()
def cleanup(self):
self.reset()
if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d)
self.marker_2d = None
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()

View File

@@ -17,27 +17,13 @@ class EntryValidator:
raise ValueError(f"Device '{name}' not found in current BEC session")
device = self.devices[name]
# Build list of available signal entries from device._info['signals']
signals_dict = getattr(device, "_info", {}).get("signals", {})
available_entries = [
sig.get("obj_name") for sig in signals_dict.values() if sig.get("obj_name")
]
# If no signals are found, means device is a signal, use the device name as the entry
if not available_entries:
available_entries = [name]
# edge case for if name is passed instead of full_name, should not happen
if entry in signals_dict:
entry = signals_dict[entry].get("obj_name", entry)
description = device.describe()
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in available_entries:
if entry not in description:
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. "
f"Available signals: '{available_entries}'"
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
)
return entry

View File

@@ -2,16 +2,12 @@ import functools
import sys
import traceback
import shiboken6
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
logger = bec_logger.logger
RAISE_ERROR_DEFAULT = False
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
"""
@@ -94,107 +90,29 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
return decorator
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
"""Internal function used by SafeConnect to handle weak references to slots."""
instance = weak_instance()
slot_func = weak_slot()
# Check if the python object has already been garbage collected
if instance is None or slot_func is None:
return
# Check if the python object has already been marked for deletion
if getattr(instance, "_destroyed", False):
return
# Check if the C++ object is still valid
if not shiboken6.isValid(instance):
return
if connect_args:
slot_func(*connect_args)
slot_func()
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
"""
Method to safely handle Qt signal-slot connections. The python object is only forwarded
as a weak reference to avoid stale objects.
Args:
instance: The instance to connect.
signal: The signal to connect to.
slot: The slot to connect.
Example:
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
"""
weak_instance = safe_ref(instance)
weak_slot = safe_ref(slot)
# Create a partial function that will check weak references before calling the actual slot
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
# Connect the signal to the safe connect slot wrapper
return signal.connect(safe_slot)
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
to the passed function, to display errors instead of potentially raising an exception
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
otherwise error display is left to the original exception hook
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
useful to prevent function calls from already deleted objects.
'raise_error' keyword argument can be passed with boolean value if the error should be raised
after the error is displayed. This is useful to propagate the error to the caller but should be used
with great care to avoid segfaults.
The keywords above are stored in a container which can be overridden by passing
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
This is useful to override the default behavior of the decorator for a specific function call.
"""
_slot_params = {
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
"raise_error": bool(slot_kwargs.pop("raise_error", RAISE_ERROR_DEFAULT)),
}
popup_error = bool(slot_kwargs.pop("popup_error", False))
def error_managed(method):
@Slot(*slot_args, **slot_kwargs)
@functools.wraps(method)
def wrapper(*args, **kwargs):
_override_slot_params = kwargs.pop("_override_slot_params", {})
_slot_params.update(_override_slot_params)
try:
if not _slot_params["verify_sender"] or len(args) == 0:
return method(*args, **kwargs)
_instance = args[0]
if not isinstance(_instance, QObject):
return method(*args, **kwargs)
sender = _instance.sender()
if sender is None:
logger.info(
f"Sender is None for {method.__module__}.{method.__qualname__}, "
"skipping method call."
)
return
return method(*args, **kwargs)
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if _slot_params["popup_error"]:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
if _slot_params["raise_error"]:
raise
return wrapper

View File

@@ -1,9 +1,7 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
@@ -14,63 +12,36 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.clickable_label import ClickableLabel
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all"
COLLAPSED_ICON_NAME: str = "expand_all"
def __init__(
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
) -> None:
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
super().__init__(parent=parent)
self._expanded = expanded
self._title_text = f"<b>{title}</b>"
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(5, 0, 0, 0)
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._expansion_button = QToolButton()
self._update_icon()
self._title = QLabel(f"<b>{title}</b>")
self._title_layout.addWidget(self._expansion_button)
self._title_layout.addWidget(self._title)
self._create_title_layout(title, icon)
self._contents = QWidget(self)
self._contents = QWidget()
self._layout.addWidget(self._contents)
self._expansion_button.clicked.connect(self.switch_expanded_state)
self.expanded = self._expanded # type: ignore
self.expansion_state_changed.emit()
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._internal_title_layout = QHBoxLayout()
self._title_layout.addLayout(self._internal_title_layout)
self._title = ClickableLabel()
self._set_title_text(self._title_text)
self._title_icon = ClickableLabel()
self._internal_title_layout.addWidget(self._title_icon)
self._internal_title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._internal_title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def get_title_layout(self) -> QHBoxLayout:
return self._internal_title_layout
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
@@ -79,8 +50,7 @@ class ExpandableGroupFrame(QFrame):
@SafeSlot()
def switch_expanded_state(self):
self.expanded = not self.expanded # type: ignore
self._update_expansion_icon()
self.expansion_state_changed.emit()
self._update_icon()
@SafeProperty(bool)
def expanded(self): # type: ignore
@@ -91,9 +61,8 @@ class ExpandableGroupFrame(QFrame):
self._expanded = expanded
self._contents.setVisible(expanded)
self.updateGeometry()
self.adjustSize()
def _update_expansion_icon(self):
def _update_icon(self):
self._expansion_button.setIcon(
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
if self.expanded
@@ -101,48 +70,3 @@ class ExpandableGroupFrame(QFrame):
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
)
)
@SafeProperty(str)
def icon_name(self): # type: ignore
return self._title_icon_name
@icon_name.setter
def icon_name(self, icon_name: str):
self._title_icon_name = icon_name
self._set_title_icon(self._title_icon_name)
def _set_title_icon(self, icon_name: str):
if icon_name:
self._title_icon.setVisible(True)
self._title_icon.setPixmap(
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
)
else:
self._title_icon.setVisible(False)
@SafeProperty(str)
def title_text(self): # type: ignore
return self._title_text
@title_text.setter
def title_text(self, title_text: str):
self._title_text = title_text
self._set_title_text(self._title_text)
def _set_title_text(self, title_text: str):
self._title.setText(title_text)
# Application example
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
frame = ExpandableGroupFrame()
layout = QVBoxLayout()
frame.set_layout(layout)
layout.addWidget(QLabel("test1"))
layout.addWidget(QLabel("test2"))
layout.addWidget(QLabel("test3"))
frame.show()
app.exec()

View File

@@ -8,8 +8,6 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger
@@ -17,13 +15,11 @@ class WidgetFilterHandler(ABC):
"""Abstract base class for widget filter handlers"""
@abstractmethod
def set_selection(self, widget, selection: list[str | tuple]) -> None:
def set_selection(self, widget, selection: list) -> None:
"""Set the filtered_selection for the widget
Args:
widget: Widget instance
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
selection (list): Filtered selection of items
"""
@abstractmethod
@@ -38,37 +34,17 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection
"""
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget"""
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
def set_selection(self, widget: QLineEdit, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QLineEdit): The QLineEdit widget
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
selection (list): Filtered selection of items
"""
if isinstance(selection, tuple):
# If selection is a tuple, it contains (text, data) pairs
selection = [text for text, _ in selection]
if not isinstance(widget.completer, QCompleter):
completer = QCompleter(widget)
widget.setCompleter(completer)
@@ -88,47 +64,19 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget"""
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
def set_selection(self, widget: QComboBox, selection: list) -> None:
"""Set the selection for the widget to the completer model
Args:
widget (QComboBox): The QComboBox widget
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
selection (list): Filtered selection of items
"""
widget.clear()
if len(selection) == 0:
return
for element in selection:
if isinstance(element, str):
widget.addItem(element)
elif isinstance(element, tuple):
# If element is a tuple, it contains (text, data) pairs
widget.addItem(*element)
widget.addItems(selection)
def check_input(self, widget: QComboBox, text: str) -> bool:
"""Check if the input text is in the filtered selection
@@ -142,40 +90,6 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
"""
return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO:
"""Public interface to set filters for input widgets.
@@ -185,14 +99,13 @@ class FilterIO:
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
@staticmethod
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
def set_selection(widget, selection: list, ignore_errors=True):
"""
Retrieve value from the widget instance.
Args:
widget: Widget instance.
selection (list[str | tuple]): Filtered selection of items.
If tuple, it contains (text, data) pairs.
selection(list): List of filtered selection items.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = FilterIO._find_handler(widget)
@@ -226,35 +139,6 @@ class FilterIO:
)
return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod
def _find_handler(widget):
"""

View File

@@ -1,305 +0,0 @@
from __future__ import annotations
from types import GenericAlias, NoneType, UnionType
from typing import NamedTuple
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
DynamicFormItemType,
FormItemSpec,
widget_from_type,
)
logger = bec_logger.logger
class GridRow(NamedTuple):
i: int
label: QLabel
widget: DynamicFormItem
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = True
USER_ACCESS = ["enabled", "enabled.setter"]
def __init__(
self,
parent=None,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
"""
if items is not None and form_item_specs is not None:
logger.error(
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
)
items = None
if items is None and form_item_specs is None:
logger.error("Must specify one and only one of items and form_item_specs!")
items = []
super().__init__(parent=parent, client=client, **kwargs)
self._items = form_item_specs or [
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
for name, item_type in items # type: ignore
]
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._enabled: bool = enabled
self._form_grid_container = QWidget(parent=self)
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid_container.setLayout(QVBoxLayout())
self._layout.addWidget(self._form_grid_container)
self._form_grid = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._form_grid.setLayout(self._new_grid_layout())
self._widget_types: dict | None = None
self._widget_from_type = widget_from_type
self._post_init()
def _post_init(self):
"""Override this if a subclass should do things after super().__init__ and before populate()"""
self.populate()
self.enabled = self._enabled # type: ignore # QProperty
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
gl: QGridLayout = self._form_grid.layout()
gl.setRowStretch(gl.rowCount(), 1)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(parent=self._form_grid, text=item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
widget.valueChanged.connect(self.value_changed)
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
grid.addWidget(widget, row, 1)
def enumerate_form_widgets(self):
"""Return a generator over the rows of the form, with the row number, the label widget (to
which the field name is attached as a property "_model_field_name"), and the entry widget"""
grid: QGridLayout = self._form_grid.layout() # type: ignore
for i in range(grid.rowCount() - 1): # One extra row for stretch
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
return {
row.label.property("_model_field_name"): row.widget.getValue()
for row in self.enumerate_form_widgets()
}
def _clear_grid(self):
gl = self._form_grid.layout()
while w := gl.takeAt(0):
w = w.widget()
if hasattr(w, "teardown"):
w.teardown()
w.deleteLater()
self._form_grid_container.layout().removeWidget(self._form_grid)
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self.update_size()
def update_size(self):
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout(self)
new_grid.setContentsMargins(0, 0, 0, 0)
return new_grid
@property
def widget_dict(self):
return {
row.label.property("_model_field_name"): row.widget
for row in self.enumerate_form_widgets()
}
@SafeProperty(bool)
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value: bool):
self._enabled = value
self.setEnabled(value)
class PydanticModelForm(TypedForm):
form_data_updated = Signal(dict)
form_data_cleared = Signal(NoneType)
validity_proc = Signal(bool)
def __init__(
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
"""
A form generated from a pydantic model.
Args:
data_model (type[BaseModel]): the model class for which to generate a form.
enabled (bool, optional): whether fields are enabled for editing.
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
"""
self._pretty_display = pretty_display
self._md_schema = data_model
super().__init__(
parent=parent,
form_item_specs=self._form_item_specs(),
enabled=enabled,
client=client,
**kwargs,
)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
self._validity_message = QLabel("Not yet validated")
self._validity.addWidget(self._validity_message)
self._layout.addWidget(self._validity)
self.value_changed.connect(self.validate_form)
self._connect_to_theme_change()
@SafeSlot()
def clear(self): ...
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
def set_data(self, data: BaseModel):
"""Fill the data for the form.
Args:
data (BaseModel): the data to enter into the form. Must be the same type as the
currently set schema, raises TypeError otherwise."""
if not self._md_schema:
raise ValueError("Schema not set - can't set data")
if not isinstance(data, self._md_schema):
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
for form_item in self.enumerate_form_widgets():
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
def _form_item_specs(self):
return [
FormItemSpec(
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
)
for name, info in self._md_schema.model_fields.items()
]
def update_items_from_schema(self):
self._items = self._form_item_specs()
def populate(self):
self.update_items_from_schema()
super().populate()
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._dict_from_grid()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on form_data_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.form_data_updated.emit(metadata_dict)
self.validity_proc.emit(True)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
return False
class PydanticModelFormItem(DynamicFormItem):
def __init__(
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
) -> None:
self._data_model = model
super().__init__(parent=parent, spec=spec)
self._main_widget.form_data_updated.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = PydanticModelForm(data_model=self._data_model)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.get_form_data()
def setValue(self, value: dict):
self._main_widget.set_data(self._data_model.model_validate(value))

View File

@@ -1,699 +0,0 @@
from __future__ import annotations
import inspect
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, NoneType, UnionType
from typing import (
Any,
Callable,
Final,
Generic,
Iterable,
Literal,
NamedTuple,
Optional,
OrderedDict,
Protocol,
TypeVar,
get_args,
runtime_checkable,
)
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from qtpy import QtCore
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import (
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QRadioButton,
QSizePolicy,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata._util import (
clearable_required,
field_default,
field_limits,
field_maxlen,
field_minlen,
field_precision,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias | Optional[Any]
name: str
info: FieldInfo = FieldInfo()
pretty_display: bool = Field(
default=False,
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
)
@field_validator("item_type", mode="before")
@classmethod
def _validate_type(cls, v):
allowed_primitives = [str, int, float, bool]
if isinstance(v, (type, UnionType)):
return v
if isinstance(v, GenericAlias):
if v.__origin__ in [list, dict, set] and all(
arg in allowed_primitives for arg in v.__args__
):
return v
raise ValueError(
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
)
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
arg_types = set(type(arg) for arg in v.__args__)
if len(arg_types) != 1:
raise ValueError("Mixtures of literal types are not supported!")
if (t := arg_types.pop()) in allowed_primitives:
return t
raise ValueError(f"Literals of type {t} are not supported")
class ClearableBoolEntry(QWidget):
stateChanged = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
self._entry = QButtonGroup()
self._true = QRadioButton("true", parent=self)
self._false = QRadioButton("false", parent=self)
for button in [self._true, self._false]:
self._layout.addWidget(button)
self._entry.addButton(button)
button.toggled.connect(self.stateChanged)
def clear(self):
self._entry.setExclusive(False)
self._true.setChecked(False)
self._false.setChecked(False)
self._entry.setExclusive(True)
def isChecked(self) -> bool | None:
if not self._true.isChecked() and not self._false.isChecked():
return None
return self._true.isChecked()
def setChecked(self, value: bool | None):
if value is None:
self.clear()
elif value:
self._true.setChecked(True)
self._false.setChecked(False)
else:
self._true.setChecked(False)
self._false.setChecked(True)
def setToolTip(self, tooltip: str):
self._true.setToolTip(tooltip)
self._false.setToolTip(tooltip)
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
"""
Initializes the form item widget.
Args:
parent (QWidget | None, optional): The parent widget. Defaults to None.
spec (FormItemSpec): The specification for the form item.
"""
super().__init__(parent)
self._spec = spec
self._layout = QHBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
self._default = field_default(self._spec.info)
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
# Sadly, QWidget and ABC are not compatible
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
else:
self._set_pretty_display()
@abstractmethod
def getValue(self) -> DynamicFormItemType: ...
@abstractmethod
def setValue(self, value): ...
@abstractmethod
def _add_main_widget(self) -> None:
self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@SafeSlot()
def clear(self, *_):
return
def _set_pretty_display(self):
self.setEnabled(False)
if button := getattr(self, "_clear_button", None):
button.setVisible(False)
def _describe(self, pad=" "):
return pad + (self._desc if self._desc else "")
def _add_clear_button(self):
self._clear_button = QToolButton()
self._clear_button.setIcon(
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
)
self._layout.addWidget(self._clear_button)
# the widget added in _add_main_widget must implement .clear() if value is not required
self._clear_button.setToolTip("Clear value or reset to default.")
self._clear_button.clicked.connect(self.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
def teardown(self):
self._layout.deleteLater()
self._layout.removeWidget(self._main_widget)
self._main_widget.deleteLater()
self._main_widget = None
class StrFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QLineEdit()
self._layout.addWidget(self._main_widget)
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
if max_length:
self._main_widget.setMaxLength(max_length)
self._main_widget.setToolTip(
f"(length min: {min_length} max: {max_length}){self._describe()}"
)
if self._default:
self._main_widget.setText(self._default)
self._add_clear_button()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.text()
def setValue(self, value: str):
if value is None:
return self._main_widget.setText("")
self._main_widget.setText(str(value))
class IntFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = QSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
if self._default is not None:
self._main_widget.setValue(self._default)
self._add_clear_button()
else:
self._main_widget.clear()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.value()
def setValue(self, value: int):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(value)
class FloatDecimalFormItem(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.textChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
precision = field_precision(self._spec.info)
self._main_widget = QDoubleSpinBox()
self._layout.addWidget(self._main_widget)
min_, max_ = field_limits(self._spec.info, float, precision)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
if self._default is not None:
self._main_widget.setValue(self._default)
self._add_clear_button()
else:
self._main_widget.clear()
def getValue(self):
if self._main_widget.text() == "":
return self._default
return self._main_widget.value()
def setValue(self, value: float | Decimal):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(float(value))
class BoolFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
if clearable_required(self._spec.info):
self._main_widget = ClearableBoolEntry()
else:
self._main_widget = QCheckBox()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
def getValue(self):
return self._main_widget.isChecked()
def setValue(self, value):
self._main_widget.setChecked(value)
class BoolToggleFormItem(BoolFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.default is PydanticUndefined:
spec.info.default = False
super().__init__(parent=parent, spec=spec)
def _add_main_widget(self) -> None:
self._main_widget = ToggleSwitch()
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
if self._default is not None:
self._main_widget.setChecked(self._default)
class DictFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.data_changed.connect(self._value_changed)
if spec.info.default is not PydanticUndefined:
self._main_widget.set_default(spec.info.default)
def _set_pretty_display(self):
self._main_widget.set_button_visibility(False)
super()._set_pretty_display()
def _add_main_widget(self) -> None:
self._main_widget = DictBackedTable(self, [])
self._layout.addWidget(self._main_widget)
self._main_widget.setToolTip(self._describe(""))
def getValue(self):
return self._main_widget.dump_dict()
def setValue(self, value):
self._main_widget.replace_data(value)
_IW = TypeVar("_IW", bound=int | float | str)
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
item: type[_IW]
widget: type[QWidget]
default: _IW
class ListFormItem(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
if spec.info.annotation is list:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
elif isinstance(spec.info.annotation, GenericAlias):
args = set(typing.get_args(spec.info.annotation))
if args == {str}:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
if args == {int}:
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
if args == {float} or args == {int, float}:
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
else:
self._types = _ItemAndWidgetType(str, QLineEdit, "")
super().__init__(parent=parent, spec=spec)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget: QListWidget
self._data = []
self._min_lines = 2 if spec.pretty_display else 4
self._repop(self._data)
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 4)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
self._layout.addWidget(self._main_widget)
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self._add_buttons()
def _add_buttons(self):
self._button_holder = QWidget()
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._buttons = QVBoxLayout()
self._buttons.setContentsMargins(0, 0, 0, 0)
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_remove_button_holder = QWidget()
self._add_remove_button_layout = QHBoxLayout()
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
self._add_button = QPushButton("+")
self._add_button.setMinimumHeight(15)
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setMinimumHeight(15)
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_remove_button_holder)
self._add_remove_button_layout.addWidget(self._add_button)
self._add_remove_button_layout.addWidget(self._remove_button)
def _set_pretty_display(self):
super()._set_pretty_display()
self._button_holder.setHidden(True)
def _repop(self, data):
self._main_widget.clear()
for val in data:
self._add_list_item(val)
self.scale_to_data()
def _add_data_item(self, val=None):
val = val or self._types.default
self._data.append(val)
self._add_list_item(val)
self._repop(self._data)
def _item_height(self):
return int(QFontMetrics(self.font()).height() * 1.5)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
item_widget.setMinimumHeight(self._item_height())
self._main_widget.setGridSize(QSize(0, self._item_height()))
if (layout := item_widget.layout()) is not None:
layout.setContentsMargins(0, 0, 0, 0)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
WidgetIO.connect_widget_change_signal(item_widget, self._update)
return item_widget
def _update(self, _, value, *args):
self._data[self._main_widget.currentRow()] = value
@SafeSlot()
def _add_row(self):
self._add_data_item(self._types.default)
self._repop(self._data)
@SafeSlot()
def _delete_row(self):
if selected := self._main_widget.currentItem():
self._main_widget.removeItemWidget(selected)
row = self._main_widget.currentRow()
self._main_widget.takeItem(row)
self._data.pop(row)
self._repop(self._data)
@SafeSlot()
def clear(self):
self._repop([])
def getValue(self):
return self._data
def setValue(self, value: Iterable):
if set(map(type, value)) | {self._types.item} != {self._types.item}:
raise ValueError(f"This widget only accepts items of type {self._types.item}")
self._data = list(value)
self._repop(self._data)
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
class SetFormItem(ListFormItem):
def _add_main_widget(self) -> None:
super()._add_main_widget()
self._add_item_field = self._types.widget()
self._buttons.addWidget(QLabel("Add new:"))
self._buttons.addWidget(self._add_item_field)
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
@SafeSlot()
def _add_row(self):
self._add_data_item(WidgetIO.get_value(self._add_item_field))
self._repop(self._data)
def _update(self, _, value, *args):
if value in self._data:
return
return super()._update(_, value, *args)
def _add_data_item(self, val=None):
val = val or self._types.default
if val == self._types.default or val in self._data:
return
self._data.append(val)
self._add_list_item(val)
def _add_list_item(self, val):
item_widget = super()._add_list_item(val)
if isinstance(item_widget, QLineEdit):
item_widget.setReadOnly(True)
return item_widget
def getValue(self):
return set(self._data)
def setValue(self, value: set):
return super().setValue(set(value))
class StrLiteralFormItem(DynamicFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(self._spec.info.annotation)
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
def getValue(self):
if self._main_widget.currentIndex() == -1:
return None
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
return
for i in range(self._main_widget.count()):
if self._main_widget.itemText(i) == value:
self._main_widget.setCurrentIndex(i)
return
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
def clear(self):
self._main_widget.setCurrentIndex(-1)
class OptionalStrLiteralFormItem(StrLiteralFormItem):
def _add_main_widget(self) -> None:
self._main_widget = QComboBox()
self._options = get_args(get_args(self._spec.info.annotation)[0])
for opt in self._options:
self._main_widget.addItem(opt)
self._layout.addWidget(self._main_widget)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
@runtime_checkable
class _ItemTypeFn(Protocol):
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
WidgetTypeRegistry = OrderedDict[
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
]
def _is_string_literal(t: type):
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
def _is_optional_string_literal(t: type):
if not hasattr(t, "__args__"):
return False
if len(t.__args__) != 2:
return False
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
return True
return False
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
# and delete/insert keys or change the order
"literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
"optional_literal_str": (
lambda spec: _is_optional_string_literal(spec.info.annotation),
OptionalStrLiteralFormItem,
),
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
"float_decimal": (
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
FloatDecimalFormItem,
),
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
"dict": (
lambda spec: spec.item_type in [dict, dict | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
DictFormItem,
),
"list": (
lambda spec: spec.item_type in [list, list | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
ListFormItem,
),
"set": (
lambda spec: spec.item_type in [set, set | None]
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
SetFormItem,
),
}
def widget_from_type(
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
) -> type[DynamicFormItem]:
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
return widget_type
return widget_type(spec)
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)
return StrFormItem
if __name__ == "__main__": # pragma: no cover
class TestModel(BaseModel):
value0: set = Field(set(["a", "b"]))
value1: str | None = Field(None)
value2: bool | None = Field(None)
value3: bool = Field(True)
value4: int = Field(123)
value5: int | None = Field()
value6: list[int] = Field()
value7: list = Field()
literal: Literal["a", "b", "c"]
nullable_literal: Literal["a", "b", "c"] | None = None
app = QApplication([])
w = QWidget()
layout = QGridLayout()
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
layout.addWidget(QLabel(field_name), i, 0)
widg = widget_from_type(spec)(spec=spec)
items.append(widg)
layout.addWidget(widg, i, 1)
items[6].setValue([1, 2, 3, 4])
items[7].setValue(["1", "2", "asdfg", "qwerty"])
w.show()
app.exec()

View File

@@ -1,21 +0,0 @@
import bec_qthemes
def pretty_display_theme(theme: str = "dark"):
palette = bec_qthemes.load_palette(theme)
foreground = palette.text().color().name()
background = palette.base().color().name()
border = palette.shadow().color().name()
accent = palette.accent().color().name()
return f"""
QWidget {{color: {foreground}; background-color: {background}}}
QLabel {{ font-weight: bold; }}
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
QRadioButton {{ color: {foreground}; }}
QRadioButton::indicator::checked {{ color: {accent}; }}
QCheckBox {{ color: {accent}; }}
"""
if __name__ == "__main__":
print(pretty_display_theme())

View File

@@ -5,12 +5,7 @@ from typing import NamedTuple
from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
class PluginFilenames(NamedTuple):
@@ -27,7 +22,7 @@ class DesignerPluginInfo:
def __init__(self, plugin_class):
self.plugin_class = plugin_class
self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
@@ -43,6 +38,21 @@ class DesignerPluginInfo:
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
@staticmethod
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()
class DesignerPluginGenerator:
def __init__(self, widget: type):
@@ -93,20 +103,24 @@ class DesignerPluginGenerator:
# Check if the widget class calls the super constructor with parent argument
init_source = inspect.getsource(self.widget.__init__)
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
cls_init_found = class_re.search(init_source) is not None
super_self_re = re.compile(
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
cls_init_found = (
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
)
super_init_found = (
bool(
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
)
super_init_found = super_self_re.search(init_source) is not None
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
# for the new style classes, we only have one super call. We can therefore check if the
# number of __init__ calls is 2 (the class itself and the super class)
num_inits = re.findall(r"__init__", init_source)
if len(num_inits) == 2 and not super_init_found:
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
super_init_found = (
bool(init_source.find("super().__init__(parent=parent") > 0)
or bool(init_source.find("super().__init__(parent,") > 0)
or bool(init_source.find("super().__init__(parent)") > 0)
)
if not cls_init_found and not super_init_found:
raise ValueError(

View File

@@ -1,735 +0,0 @@
"""Module providing a guided help system for creating interactive GUI tours."""
from __future__ import annotations
import sys
import weakref
from typing import Callable, Dict, List, TypedDict
from uuid import uuid4
import louie
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from louie import saferef
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
from qtpy.QtGui import QAction, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenuBar,
QPushButton,
QToolBar,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class TourStep(TypedDict):
"""Type definition for a tour step."""
widget_ref: (
louie.saferef.BoundMethodWeakref
| weakref.ReferenceType[
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
]
| Callable[[], tuple[QWidget | QAction, str | None]]
| None
)
text: str
title: str
class TutorialOverlay(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Keep mouse events enabled for the overlay but we'll handle them manually
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
self.current_rect = QRect()
self.message_box = self._create_message_box()
self.message_box.hide()
def _create_message_box(self):
box = QFrame(self)
app = QApplication.instance()
bg_color = app.palette().window().color()
box.setStyleSheet(
f"""
QFrame {{
background-color: {bg_color.name()};
border-radius: 8px;
padding: 8px;
}}
"""
)
layout = QVBoxLayout(box)
# Top layout with close button (left) and step indicator (right)
top_layout = QHBoxLayout()
# Close button on the left with material icon
self.close_btn = QPushButton()
self.close_btn.setIcon(material_icon("close"))
self.close_btn.setToolTip("Close")
self.close_btn.setMaximumSize(32, 32)
# Step indicator on the right
self.step_label = QLabel()
self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;")
top_layout.addWidget(self.close_btn)
top_layout.addStretch()
top_layout.addWidget(self.step_label)
# Main content label
self.label = QLabel()
self.label.setWordWrap(True)
# Bottom navigation buttons
btn_layout = QHBoxLayout()
# Back button with material icon
self.back_btn = QPushButton("Back")
self.back_btn.setIcon(material_icon("arrow_back"))
# Next button with material icon
self.next_btn = QPushButton("Next")
self.next_btn.setIcon(material_icon("arrow_forward"))
btn_layout.addStretch()
btn_layout.addWidget(self.back_btn)
btn_layout.addWidget(self.next_btn)
layout.addLayout(top_layout)
layout.addWidget(self.label)
layout.addLayout(btn_layout)
return box
def paintEvent(self, event): # pylint: disable=unused-argument
if not self.current_rect.isValid():
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Create semi-transparent overlay color
overlay_color = QColor(0, 0, 0, 160)
# Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive.
r = self.current_rect
rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height()
# Paint overlay in 4 regions around the highlighted widget using exclusive bounds
# Top region (everything above the highlight)
if rect_y > 0:
top_rect = QRect(0, 0, self.width(), rect_y)
painter.fillRect(top_rect, overlay_color)
# Bottom region (everything below the highlight)
bottom_y = rect_y + rect_h
if bottom_y < self.height():
bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y)
painter.fillRect(bottom_rect, overlay_color)
# Left region (to the left of the highlight)
if rect_x > 0:
left_rect = QRect(0, rect_y, rect_x, rect_h)
painter.fillRect(left_rect, overlay_color)
# Right region (to the right of the highlight)
right_x = rect_x + rect_w
if right_x < self.width():
right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h)
painter.fillRect(right_rect, overlay_color)
# Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap.
border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4)
painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(border_rect, 8, 8)
painter.end()
def show_step(
self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1
):
"""
rect must already be in the overlay's coordinate space (i.e. mapped).
This method positions the message box so it does not overlap the rect.
Args:
rect(QRect): rectangle to highlight
title(str): Title text for the step
text(str): Main content text for the step
current_step(int): Current step number
total_steps(int): Total number of steps in the tour
"""
self.current_rect = rect
# Update step indicator in top right
self.step_label.setText(f"Step {current_step} of {total_steps}")
# Update main content text (without step number since it's now in top right)
content_text = f"<b>{title}</b><br>{text}" if title else text
self.label.setText(content_text)
self.message_box.adjustSize() # ensure layout applied
message_size = self.message_box.size() # actual widget size (width, height)
spacing = 15
# Preferred placement: to the right, vertically centered
pos_x = rect.right() + spacing
pos_y = rect.center().y() - (message_size.height() // 2)
# If it would go off the right edge, try left of the widget
if pos_x + message_size.width() > self.width():
pos_x = rect.left() - message_size.width() - spacing
# vertical center is still good, but if that overlaps top/bottom we'll clamp below
# If it goes off the left edge (no space either side), place below, centered horizontally
if pos_x < spacing:
pos_x = rect.center().x() - (message_size.width() // 2)
pos_y = rect.bottom() + spacing
# If it goes off the bottom, try moving it above the widget
if pos_y + message_size.height() > self.height() - spacing:
# if there's room above the rect, put it there
candidate_y = rect.top() - message_size.height() - spacing
if candidate_y >= spacing:
pos_y = candidate_y
else:
# otherwise clamp to bottom with spacing
pos_y = max(spacing, self.height() - message_size.height() - spacing)
# If it goes off the top, clamp down
pos_y = max(spacing, pos_y)
# Make sure we don't poke the left edge
pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing))
# Apply geometry and show
self.message_box.setGeometry(
int(pos_x), int(pos_y), message_size.width(), message_size.height()
)
self.message_box.show()
self.update()
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Resize:
self.setGeometry(obj.rect())
return False
class GuidedTour(QObject):
"""
A guided help system for creating interactive GUI tours.
Allows developers to register widgets with help text and create guided tours.
"""
tour_started = Signal()
tour_finished = Signal()
step_changed = Signal(int, int) # current_step, total_steps
def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True):
super().__init__()
self._visible_check: bool = enforce_visibility
self.main_window_ref = saferef.safe_ref(main_window)
self.overlay = None
self._registered_widgets: Dict[str, TourStep] = {}
self._tour_steps: List[TourStep] = []
self._current_index = 0
self._active = False
@property
def main_window(self) -> QWidget | None:
"""Get the main window from weak reference."""
if self.main_window_ref and callable(self.main_window_ref):
widget = self.main_window_ref()
if isinstance(widget, QWidget):
return widget
return None
def register_widget(
self,
*,
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
text: str = "",
title: str = "",
) -> str:
"""
Register a widget with help text for tours.
Args:
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
text (str): The help text for the widget. This will be shown during the tour.
title (str, optional): A title for the widget (defaults to its class name or action text).
Returns:
str: The unique ID for the registered widget.
"""
step_id = str(uuid4())
# If it's a plain callable
if callable(widget) and not hasattr(widget, "__self__"):
widget_ref = widget
default_title = "Widget"
elif isinstance(widget, QAction):
widget_ref = weakref.ref(widget)
default_title = widget.text() or "Action"
elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button):
def _resolve_toolbar_button(toolbar_action=widget):
button = toolbar_action.get_toolbar_button()
return (button, None)
widget_ref = _resolve_toolbar_button
default_title = getattr(widget, "tooltip", "Toolbar Menu")
else:
widget_ref = saferef.safe_ref(widget)
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
self._registered_widgets[step_id] = {
"widget_ref": widget_ref,
"text": text,
"title": title or default_title,
}
logger.debug(f"Registered widget {title or default_title} with ID {step_id}")
return step_id
def _action_highlight_rect(self, action: QAction) -> QRect | None:
"""
Try to find the QRect in main_window coordinates that should be highlighted for the given QAction.
Returns None if not found (e.g. not visible).
"""
mw = self.main_window
if mw is None:
return None
# Try toolbars first
for tb in mw.findChildren(QToolBar):
btn = tb.widgetForAction(action)
if btn and btn.isVisible():
rect = btn.rect()
top_left = btn.mapTo(mw, rect.topLeft())
return QRect(top_left, rect.size())
# Try menu bars
menubars = []
if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)):
mb = mw.menuBar()
if mb and mb not in menubars:
menubars.append(mb)
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
for mb in menubars:
if action in mb.actions():
ar = mb.actionGeometry(action)
top_left = mb.mapTo(mw, ar.topLeft())
return QRect(top_left, ar.size())
return None
def unregister_widget(self, step_id: str) -> bool:
"""
Unregister a previously registered widget.
Args:
step_id (str): The unique ID of the registered widget.
Returns:
bool: True if the widget was unregistered, False if not found.
"""
if self._active:
raise RuntimeError("Cannot unregister widget while tour is active")
if step_id in self._registered_widgets:
if self._registered_widgets[step_id] in self._tour_steps:
self._tour_steps.remove(self._registered_widgets[step_id])
del self._registered_widgets[step_id]
return True
return False
def create_tour(self, step_ids: List[str] | None = None) -> bool:
"""
Create a tour from registered widget IDs.
Args:
step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included.
Returns:
bool: True if the tour was created successfully, False if any step IDs were not found
"""
if step_ids is None:
step_ids = list(self._registered_widgets.keys())
tour_steps = []
for step_id in step_ids:
if step_id not in self._registered_widgets:
logger.error(f"Step ID {step_id} not found")
return False
tour_steps.append(self._registered_widgets[step_id])
self._tour_steps = tour_steps
logger.info(f"Created tour with {len(tour_steps)} steps")
return True
@SafeSlot()
def start_tour(self):
"""Start the guided tour."""
if not self._tour_steps:
self.create_tour()
if self._active:
logger.warning("Tour already active")
return
main_window = self.main_window
if main_window is None:
logger.error("Main window no longer exists (weak reference is dead)")
return
self._active = True
self._current_index = 0
# Create overlay
self.overlay = TutorialOverlay(main_window)
self.overlay.setGeometry(main_window.rect())
self.overlay.show()
main_window.installEventFilter(self.overlay)
# Connect signals
self.overlay.next_btn.clicked.connect(self.next_step)
self.overlay.back_btn.clicked.connect(self.prev_step)
self.overlay.close_btn.clicked.connect(self.stop_tour)
main_window.installEventFilter(self)
self._show_current_step()
self.tour_started.emit()
logger.info("Started guided tour")
@SafeSlot()
def stop_tour(self):
"""Stop the current tour."""
if not self._active:
return
self._active = False
main_window = self.main_window
if self.overlay and main_window:
main_window.removeEventFilter(self.overlay)
self.overlay.hide()
self.overlay.deleteLater()
self.overlay = None
if main_window:
main_window.removeEventFilter(self)
self.tour_finished.emit()
logger.info("Stopped guided tour")
@SafeSlot()
def next_step(self):
"""Move to next step or finish tour if on last step."""
if not self._active:
return
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
# On last step, finish the tour
self.stop_tour()
@SafeSlot()
def prev_step(self):
"""Move to previous step."""
if not self._active:
return
if self._current_index > 0:
self._current_index -= 1
self._show_current_step()
def _show_current_step(self):
"""Display the current step."""
if not self._active or not self.overlay:
return
step = self._tour_steps[self._current_index]
step_title = step["title"]
target, step_text = self._resolve_step_target(step)
if target is None:
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
return
main_window = self.main_window
if main_window is None:
logger.error("Main window no longer exists (weak reference is dead)")
self.stop_tour()
return
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
if highlight_rect is None:
return
# Calculate step numbers
current_step = self._current_index + 1
total_steps = len(self._tour_steps)
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
# Update button states
self.overlay.back_btn.setEnabled(self._current_index > 0)
# Update next button text and state
is_last_step = self._current_index >= len(self._tour_steps) - 1
if is_last_step:
self.overlay.next_btn.setText("Finish")
self.overlay.next_btn.setIcon(material_icon("check"))
self.overlay.next_btn.setEnabled(True)
else:
self.overlay.next_btn.setText("Next")
self.overlay.next_btn.setIcon(material_icon("arrow_forward"))
self.overlay.next_btn.setEnabled(True)
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
"""
Resolve the target widget/action for the given step.
Args:
step(TourStep): The tour step to resolve.
Returns:
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
"""
widget_ref = step.get("widget_ref")
step_text = step.get("text", "")
if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)):
target = widget_ref()
else:
target = widget_ref
if target is None:
return None, step_text
if callable(target) and not isinstance(target, (QWidget, QAction)):
result = target()
if isinstance(result, tuple):
target, alt_text = result
if alt_text:
step_text = alt_text
else:
target = result
return target, step_text
def _get_highlight_rect(
self, main_window: QWidget, target: QWidget | QAction, step_title: str
) -> QRect | None:
"""
Get the QRect in main_window coordinates to highlight for the given target.
Args:
main_window(QWidget): The main window containing the target.
target(QWidget | QAction): The target widget or action to highlight.
step_title(str): The title of the current step (for logging purposes).
Returns:
QRect | None: The rectangle to highlight, or None if not found/visible.
"""
if isinstance(target, QAction):
rect = self._action_highlight_rect(target)
if rect is None:
self._advance_past_invalid_step(
step_title,
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
)
return None
return rect
if isinstance(target, QWidget):
if self._visible_check:
if not target.isVisible():
self._advance_past_invalid_step(
step_title, reason=f"Widget {target!r} is not visible."
)
return None
rect = target.rect()
top_left = target.mapTo(main_window, rect.topLeft())
return QRect(top_left, rect.size())
self._advance_past_invalid_step(
step_title, reason=f"Unsupported step target type: {type(target)}"
)
return None
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
"""
Skip the current step (or stop the tour) when the target cannot be visualised.
"""
logger.warning("%s Skipping step %r.", reason, step_title)
if self._current_index < len(self._tour_steps) - 1:
self._current_index += 1
self._show_current_step()
else:
self.stop_tour()
def get_registered_widgets(self) -> Dict[str, TourStep]:
"""Get all registered widgets."""
return self._registered_widgets.copy()
def clear_registrations(self):
"""Clear all registered widgets."""
if self._active:
self.stop_tour()
self._registered_widgets.clear()
self._tour_steps.clear()
logger.info("Cleared all registrations")
def set_visibility_enforcement(self, enabled: bool):
"""Enable or disable visibility checks when highlighting widgets."""
self._visible_check = enabled
def eventFilter(self, obj, event):
"""Handle window resize/move events to update step positioning."""
if event.type() in (QEvent.Type.Move, QEvent.Type.Resize):
if self._active:
self._show_current_step()
return super().eventFilter(obj, event)
################################################################################
############ # Example usage of GuidedTour system ##############################
################################################################################
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Guided Tour Demo")
central = QWidget()
layout = QVBoxLayout(central)
layout.setSpacing(12)
layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support."))
self.btn1 = QPushButton("Primary Button")
self.btn2 = QPushButton("Secondary Button")
self.status_label = QLabel("Use the controls below or the toolbar to interact.")
self.start_tour_btn = QPushButton("Start Guided Tour")
layout.addWidget(self.btn1)
layout.addWidget(self.btn2)
layout.addWidget(self.status_label)
layout.addStretch()
layout.addWidget(self.start_tour_btn)
self.setCentralWidget(central)
# Guided tour system
self.guided_help = GuidedTour(self)
# Menus for demonstrating QAction support in menu bars
self._init_menu_bar()
# Modular toolbar showcasing QAction targets
self._init_toolbar()
# Register widgets and actions with help text
primary_step = self.guided_help.register_widget(
widget=self.btn1,
text="The primary button updates the status text when clicked.",
title="Primary Button",
)
secondary_step = self.guided_help.register_widget(
widget=self.btn2,
text="The secondary button complements the demo layout.",
title="Secondary Button",
)
toolbar_action_step = self.guided_help.register_widget(
widget=self.toolbar_tour_action.action,
text="Toolbar actions are supported in the guided tour. This one also starts the tour.",
title="Toolbar Tour Action",
)
tools_menu_step = self.guided_help.register_widget(
widget=self.toolbar.components.get_action("menu_tools"),
text="Expandable toolbar menus group related actions. This button opens the tools menu.",
title="Tools Menu",
)
# Create tour from registered widgets
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
widget_ids = self.tour_step_ids
self.guided_help.create_tour(widget_ids)
# Connect start button
self.start_tour_btn.clicked.connect(self.guided_help.start_tour)
def _init_menu_bar(self):
menu_bar = self.menuBar()
info_menu = menu_bar.addMenu("Info")
info_menu.setObjectName("info-menu")
self.info_menu = info_menu
self.info_menu_action = info_menu.menuAction()
self.about_action = info_menu.addAction("About This Demo")
def _init_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(self.toolbar)
self.toolbar_tour_action = MaterialIconAction(
"play_circle", tooltip="Start the guided tour", parent=self
)
self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action)
self.toolbar_highlight_action = MaterialIconAction(
"visibility", tooltip="Highlight the primary button", parent=self
)
self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action)
demo_bundle = self.toolbar.new_bundle("demo")
demo_bundle.add_action("tour-start")
demo_bundle.add_action("inspect-primary")
self._setup_tools_menu()
self.toolbar.show_bundles(["demo", "menu_tools"])
self.toolbar.refresh()
self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour)
def _setup_tools_menu(self):
self.tools_menu_actions: dict[str, MaterialIconAction] = {
"notes": MaterialIconAction(
icon_name="note_add", tooltip="Add a note", filled=True, parent=self
),
"bookmark": MaterialIconAction(
icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self
),
"settings": MaterialIconAction(
icon_name="tune", tooltip="Adjust settings", filled=True, parent=self
),
}
self.tools_menu_action = ExpandableMenuAction(
label="Tools ", actions=self.tools_menu_actions
)
self.toolbar.components.add_safe("menu_tools", self.tools_menu_action)
bundle = ToolbarBundle("menu_tools", self.toolbar.components)
bundle.add_action("menu_tools")
self.toolbar.add_bundle(bundle)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
from bec_qthemes import apply_theme
apply_theme("dark")
w = MainWindow()
w.resize(400, 300)
w.show()
sys.exit(app.exec())

View File

@@ -1,247 +0,0 @@
"""Module providing a simple help inspector tool for QtWidgets."""
from functools import partial
from typing import Callable
from uuid import uuid4
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
class HelpInspector(BECWidget, QtWidgets.QWidget):
"""
A help inspector widget that allows to inspect other widgets in the application.
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
The inspector also allows to register custom callback that are called with the inspected widget
as argument. This may be useful in the future to hook up more callbacks with custom signals.
Args:
parent (QWidget | None): The parent widget of the help inspector.
client: Optional client for BECWidget functionality.
size (tuple[int, int]): Optional size of the icon for the help inspector.
"""
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self._app = QtWidgets.QApplication.instance()
layout = QtWidgets.QHBoxLayout(self) # type: ignore
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._active = False
self._init_ui()
self._callbacks = {}
# Register the default callbacks
self._register_default_callbacks()
# Connect the button toggle signal
self._button.toggled.connect(self._toggle_mode)
def _init_ui(self):
"""Init the UI components."""
colors: AccentColors = get_accent_colors()
self._button = QtWidgets.QToolButton(self.parent())
self._button.setCheckable(True)
self._icon_checked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
)
self._icon_unchecked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
)
self._button.setText("Help Inspect Tool")
self._button.setIcon(self._icon_unchecked())
self._button.setToolTip("Click to enter Help Mode")
self.layout().addWidget(self._button)
def apply_theme(self, theme: str) -> None:
colors = get_accent_colors()
self._icon_checked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
)
self._icon_unchecked = partial(
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
)
if self._active:
self._button.setIcon(self._icon_checked())
else:
self._button.setIcon(self._icon_unchecked())
@SafeSlot(bool)
def _toggle_mode(self, enabled: bool):
"""
Toggle the help inspection mode.
Args:
enabled (bool): Whether to enable or disable the help inspection mode.
"""
if self._app is None:
self._app = QtWidgets.QApplication.instance()
self._active = enabled
if enabled:
self._app.installEventFilter(self)
self._button.setIcon(self._icon_checked())
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
else:
self._app.removeEventFilter(self)
self._button.setIcon(self._icon_unchecked())
self._button.setChecked(False)
QtWidgets.QApplication.restoreOverrideCursor()
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
"""
Filter events to capture Key_Escape event, and mouse clicks
if event filter is active. Any click event on a widget is suppressed, if
the Inspector is active, and the registered callbacks are called with
the clicked widget as argument.
Args:
obj (QObject): The object that received the event.
event (QEvent): The event to filter.
"""
# If not active, return immediately
if not self._active:
return super().eventFilter(obj, event)
# If active, handle escape key
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
self._toggle_mode(False)
return super().eventFilter(obj, event)
# If active, and left mouse button pressed, handle click
if event.type() == QtCore.QEvent.MouseButtonPress:
if event.button() == QtCore.Qt.LeftButton:
widget = self._app.widgetAt(event.globalPos())
if widget is None:
return super().eventFilter(obj, event)
# Get BECWidget ancestor
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
if not isinstance(widget, BECWidget):
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
if widget:
if widget is self:
self._toggle_mode(False)
return True
for cb in self._callbacks.values():
try:
cb(widget)
except Exception as e:
logger.error(f"Error occurred in callback {cb}: {e}")
return True
return super().eventFilter(obj, event)
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
"""
Register a callback to be called when a widget is inspected.
The callback should be callable with the following signature:
callback(widget: QWidget) -> None
Args:
callback (Callable[[QWidget], None]): The callback function to register.
Returns:
str: A unique ID for the registered callback.
"""
cb_id = str(uuid4())
self._callbacks[cb_id] = callback
return cb_id
def unregister_callback(self, cb_id: str):
"""Unregister a previously registered callback."""
self._callbacks.pop(cb_id, None)
def _register_default_callbacks(self):
"""Default behavior: publish tooltip, docstring, bec_help"""
def cb_doc(widget: QtWidgets.QWidget):
docstring = widget.__doc__ or "No documentation available."
self.widget_docstring.emit(docstring)
def cb_help(widget: QtWidgets.QWidget):
tooltip = widget.toolTip() or "No tooltip available."
self.widget_tooltip.emit(tooltip)
def cb_bec_help(widget: QtWidgets.QWidget):
help_text = None
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
try:
help_text = widget.get_help_md()
except Exception as e:
logger.debug(f"Error retrieving help text from {widget}: {e}")
if help_text is None:
help_text = widget.toolTip() or "No help available."
if not isinstance(help_text, str):
logger.error(
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
)
help_text = str(help_text)
self.bec_widget_help.emit(help_text)
self.register_callback(cb_doc)
self.register_callback(cb_help)
self.register_callback(cb_bec_help)
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QtWidgets.QApplication(sys.argv)
main_window = QtWidgets.QMainWindow()
apply_theme("dark")
main_window.setWindowTitle("Help Inspector Test")
central_widget = QtWidgets.QWidget()
main_layout = QtWidgets.QVBoxLayout(central_widget)
dark_mode_button = DarkModeButton(parent=main_window)
main_layout.addWidget(dark_mode_button)
help_inspector = HelpInspector()
main_layout.addWidget(help_inspector)
test_button = QtWidgets.QPushButton("Test Button")
test_button.setToolTip("This is a test button.")
test_line_edit = QtWidgets.QLineEdit()
test_line_edit.setToolTip("This is a test line edit.")
test_label = QtWidgets.QLabel("Test Label")
test_label.setToolTip("")
box = PositionerBox()
layout_1 = QtWidgets.QHBoxLayout()
layout_1.addWidget(test_button)
layout_1.addWidget(test_line_edit)
layout_1.addWidget(test_label)
layout_1.addWidget(box)
main_layout.addLayout(layout_1)
doc_label = QtWidgets.QLabel("Docstring will appear here.")
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
main_layout.addWidget(doc_label)
main_layout.addWidget(tool_tip_label)
main_layout.addWidget(bec_help_label)
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
help_inspector.widget_docstring.connect(doc_label.setText)
help_inspector.bec_widget_help.connect(bec_help_label.setText)
main_window.setCentralWidget(central_widget)
main_window.resize(400, 200)
main_window.show()
sys.exit(app.exec())

View File

@@ -1,7 +1,5 @@
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots."""
from __future__ import annotations
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots. """
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot

View File

@@ -1,133 +0,0 @@
import re
from functools import partial
from re import Pattern
from typing import Generic, Iterable, NamedTuple, TypeVar
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.control.device_manager.components._util import (
SORT_KEY_ROLE,
SortableQListWidgetItem,
)
logger = bec_logger.logger
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
def __init__(
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
) -> None:
super().__init__(parent)
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
self.item_tuple = _Items
self._item_class = item_class
self._item_dict: dict[str, _Items] = {}
def __contains__(self, id: str):
return id in self._item_dict
def clear(self) -> None:
self._item_dict = {}
return super().clear()
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
Args:
id (str): the key under which to store the list item in the internal dict
Returns:
The widget created in the addition process
"""
def _remove_item(item: QListWidgetItem):
self.takeItem(self.row(item))
del self._item_dict[id]
self.sortItems()
def _updatesize(item: QListWidgetItem, item_widget: _EF):
item_widget.adjustSize()
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
item = SortableQListWidgetItem(self)
item.setData(SORT_KEY_ROLE, id) # used for sorting
item_widget = self._item_class(*args, **kwargs)
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
item_widget.imminent_deletion.connect(partial(_remove_item, item))
item_widget.broadcast_size_hint.connect(item.setSizeHint)
self.addItem(item)
self.setItemWidget(item, item_widget)
self._item_dict[id] = self.item_tuple(item, item_widget)
item.setSizeHint(item_widget.sizeHint())
return (item, item_widget)
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
items = [self.takeItem(0) for i in range(self.count())]
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
for it in items:
self.addItem(it)
# reattach its custom widget
widget = self.itemWidget(it)
if widget:
self.setItemWidget(it, widget)
def item_widget_pairs(self):
return self._item_dict.values()
def widgets(self):
return (i.widget for i in self._item_dict.values())
def get_item_widget(self, id: str):
if (item := self._item_dict.get(id)) is None:
return None
return item
def set_hidden_pattern(self, pattern: Pattern):
self.hide_all()
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
def set_hidden(self, ids: Iterable[str]):
self._set_hidden(ids, True)
def _set_hidden(self, ids: Iterable[str], hidden: bool):
for id in ids:
if (_item := self._item_dict.get(id)) is not None:
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
else:
logger.warning(
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
)
self.sortItems()
def hide_all(self):
self.set_hidden_state_on_all(True)
def unhide_all(self):
self.set_hidden_state_on_all(False)
def set_hidden_state_on_all(self, hidden: bool):
for _item in self._item_dict.values():
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
self.sortItems()
@SafeSlot(str)
def update_filter(self, value: str):
if value == "":
return self.unhide_all()
try:
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
except Exception:
self.unhide_all()

View File

@@ -1,16 +0,0 @@
import re
def pascal_to_snake(name: str) -> str:
"""
Convert PascalCase to snake_case.
Args:
name (str): The name to be converted.
Returns:
str: The converted name.
"""
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
return s2.lower()

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
{widget_import}
@@ -21,8 +20,6 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = {plugin_name_pascal}(parent)
return t

View File

@@ -4,7 +4,7 @@ import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from typing import TYPE_CHECKING
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -90,15 +90,15 @@ class BECClassInfo:
name: str
module: str
file: str
obj: type[BECWidget]
obj: type
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
class BECClassContainer:
def __init__(self, initial: Iterable[BECClassInfo] = []):
self._collection: list[BECClassInfo] = list(initial)
def __init__(self):
self._collection: list[BECClassInfo] = []
def __repr__(self):
return str(list(cl.name for cl in self.collection))
@@ -106,16 +106,6 @@ class BECClassContainer:
def __iter__(self):
return self._collection.__iter__()
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
@@ -125,44 +115,53 @@ class BECClassContainer:
"""
self.collection.append(class_info)
@property
def names(self):
"""Return a list of class names"""
return [c.name for c in self]
@property
def collection(self):
"""Get the collection of classes."""
"""
Get the collection of classes.
"""
return self._collection
@property
def connector_classes(self):
"""Get all connector classes."""
"""
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""Get all top-level classes."""
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""Get all plugins. These are all classes that are on the top level and are widgets."""
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property
def widgets(self):
"""Get all widgets. These are all classes inheriting from BECWidget."""
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""Get all classes."""
"""
Get all classes.
"""
return [info.obj for info in self.collection]
@@ -198,10 +197,10 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
if issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)

View File

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

View File

@@ -1,12 +1,11 @@
import pyqtgraph as pg
from qtpy.QtCore import Property, Qt
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(QFrame):
# TODO this should be removed completely in favor of QSS styling, no time now
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
@@ -29,9 +28,6 @@ class RoundedFrame(QFrame):
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Ensure QSS can paint background/border on this widget
self.setAttribute(Qt.WA_StyledBackground, True)
# Create a layout for the frame
if orientation == "vertical":
self.layout = QVBoxLayout(self)
@@ -49,10 +45,22 @@ class RoundedFrame(QFrame):
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self.update_style()
def apply_theme(self, theme: str):
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if self.content_widget is not None and isinstance(
self.content_widget, pg.GraphicsLayoutWidget
):
self.content_widget.setBackground(self.background_color)
# Update background color based on the theme
if theme == "light":
self.background_color = "#e9ecef" # Subtle contrast for light mode
else:
self.background_color = "#141414" # Dark mode
self.update_style()
@Property(int)
@@ -69,21 +77,34 @@ class RoundedFrame(QFrame):
"""
Update the style of the frame based on the background color.
"""
self.setStyleSheet(
f"""
if self.background_color:
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
border-radius: {self._radius}px;
background-color: {self.background_color};
border-radius: {self._radius}; /* Rounded corners */
}}
"""
)
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
Automatically apply background, border, and axis styles to the PlotWidget.
Args:
border (str): Border style (e.g., 'none', '1px solid red').
"""
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
self.content_widget.setStyleSheet("")
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
GraphicsLayoutWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
self.content_widget.setBackground(self.background_color)
class ExampleApp(QWidget): # pragma: no cover
@@ -107,14 +128,24 @@ class ExampleApp(QWidget): # pragma: no cover
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
# Add to layout
layout.addWidget(dark_button)
layout.addWidget(plot1)
layout.addWidget(plot2)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
self.setLayout(layout)
# Theme flip demo removed; global theming applies automatically
from qtpy.QtCore import QTimer
def change_theme():
rounded_plot1.apply_theme("light")
rounded_plot2.apply_theme("dark")
QTimer.singleShot(100, change_theme)
if __name__ == "__main__": # pragma: no cover

View File

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

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -11,7 +10,7 @@ from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
@@ -19,9 +18,8 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_lib import messages
from qtpy.QtCore import QObject
else:
@@ -85,7 +83,6 @@ class RPCServer:
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self._broadcasted_data = {}
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
@@ -129,44 +126,16 @@ class RPCServer:
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
if method == "raise" and hasattr(
obj, "setWindowState"
): # special case for raising windows, should work even if minimized
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
# The procedure is as follows:
# 1. Get the current window state to check if the window is minimized and remove minimized flag
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
# and call raise_() and activateWindow()
# This forces gnome to raise the window even if focus stealing is prevented
# 3. Flag for stay on top is removed again to restore the original window state
# 4. Finally, we call show() to ensure the window is visible
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
target_state = state | Qt.WindowActive
if state & Qt.WindowMinimized:
target_state &= ~Qt.WindowMinimized
obj.setWindowState(target_state)
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
obj.showNormal()
if hasattr(obj, "raise_"):
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
obj.raise_()
if hasattr(obj, "activateWindow"):
obj.activateWindow()
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
obj.show()
res = None
else:
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
res = method_obj(*args, **kwargs)
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
@@ -220,16 +189,12 @@ class RPCServer:
if not getattr(val, "RPC", True):
continue
data[key] = self._serialize_bec_connector(val)
if self._broadcasted_data == data:
return
self._broadcasted_data = data
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
expire=60,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
@@ -247,20 +212,9 @@ class RPCServer:
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
try:
parent = connector.parent()
if isinstance(parent, BECMainWindow):
container_proxy = parent.gui_id
else:
container_proxy = None
except Exception:
container_proxy = None
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
logger.info(f"Waiting for {connector} to be registered...")
time.sleep(0.1)
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
@@ -271,7 +225,6 @@ class RPCServer:
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
}

View File

@@ -1,25 +0,0 @@
from bec_lib.codecs import BECCodec
from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF
class QPointFEncoder(BECCodec):
obj_type = QPointF
@staticmethod
def encode(obj: QPointF) -> list[float]:
"""Encode a QPointF object to a list of floats."""
return [obj.x(), obj.y()]
@staticmethod
def decode(type_name: str, data: list[float]) -> list[float]:
"""No-op function since QPointF is encoded as a list of floats."""
return data
def register_serializer_extension():
"""
Register the serializer extension for the BECConnector.
"""
if not msgpack.is_registered(QPointF):
msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)

View File

@@ -1,5 +1,5 @@
from bec_lib.logger import bec_logger
from qtpy.QtGui import QCloseEvent
from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot

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