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

Compare commits

..

1 Commits

455 changed files with 10175 additions and 43051 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,64 +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
- 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,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.10", "3.11", "3.12"]
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: 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,15 +0,0 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '00 10 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7

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

View File

@@ -13,7 +13,7 @@ variables:
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.
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
@@ -35,7 +35,8 @@ include:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
pip_args: ".[dev]"
ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
# different stages in the pipeline
stages:
@@ -77,7 +78,7 @@ formatter:
stage: Formatter
needs: []
script:
- pip install -e ./[dev]
- 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:
@@ -88,7 +89,7 @@ pylint:
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev]
- pip install -e .[dev,pyqt6]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
@@ -162,20 +163,6 @@ tests:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
matrix:
@@ -203,7 +190,7 @@ test-matrix:
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3:25.1.1-2
image: continuumio/miniconda3
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
@@ -230,7 +217,7 @@ end-2-end-conda:
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
artifacts:
when: on_failure
@@ -245,7 +232,7 @@ end-2-end-conda:
- 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.*$/"
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
semver:
stage: Deploy

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

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

View File

@@ -1,16 +1,5 @@
# 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.10%20%7C%203.11%20%7C%203.12-blue?logo=python&logoColor=white)](https://www.python.org)
[![PySide6](https://img.shields.io/badge/PySide6-blue?logo=qt&logoColor=white)](https://doc.qt.io/qtforpython/)
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](https://codecov.io/gh/bec-project/bec_widgets)
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨

View File

@@ -1,20 +0,0 @@
import os
import sys
import PySide6QtAds as QtAds
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]

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,23 +0,0 @@
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)
return _dock_area
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
"""
Create a dock area with auto update enabled.
Args:
object_name(str): The name of the dock area.
Returns:
BECDockArea: The created dock area.
"""
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -1,590 +0,0 @@
from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QWidget,
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
from bec_widgets.widgets.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__(
self,
parent: QObject | None = None,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
tile_size: tuple[int, int] | None = None,
):
super().__init__(parent=parent, orientation="vertical")
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
tile_size = self.DEFAULT_SIZE
self.tile_size = tile_size
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
size = 100
circular_pixmap = QPixmap(size, size)
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
# Desired default appearance
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setAlignment(Qt.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)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
if show_selector:
self.selector = QComboBox(self)
self.layout.addWidget(self.selector)
else:
self.selector = None
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.layout.addItem(self.spacer_bottom)
# Action button
self.action_button = QPushButton("Open")
self.action_button.setStyleSheet(
"""
QPushButton {
background-color: #007AFF;
border: none;
padding: 8px 16px;
color: white;
border-radius: 6px;
font-weight: bold;
}
QPushButton:hover {
background-color: #005BB5;
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.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
TILE_SIZE = (250, 300)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Track the smallest mainlabel font size chosen so far
self._min_main_label_pt: int | None = None
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget(self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
# Main Widget
self.central_widget = QWidget(self)
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.register_tile(
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
)
self.register_tile(
name="custom_ui_file",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
)
# plugin widgets
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
tile_size=self.TILE_SIZE,
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
tile.setSizePolicy(QSizePolicy.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
def launch(
self,
launch_script: str,
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
**kwargs,
) -> QWidget | None:
"""Launch the specified script. If the launch script creates a QWidget, it will be
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
as a separate window.
Args:
launch_script(str): The name of the script to be launched.
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
QWidget: The created dock area.
"""
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
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)
if launch_script is None:
launch_script = "dock_area"
if not isinstance(launch_script, str):
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
if launch_script == "custom_ui_file":
ui_file = kwargs.pop("ui_file", None)
if not ui_file:
return None
return self._launch_custom_ui_file(ui_file)
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
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.")
result_widget = launch(name)
result_widget.resize(result_widget.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
result_widget.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
if geometry is not None:
result_widget.setGeometry(*geometry)
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindowNoRPC()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
"""
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
instantiate it directly; otherwise, embed it in a UILaunchWindow.
"""
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
# Parse the UI to detect top-level widget class
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
widget = root.find("widget")
if widget is None:
raise ValueError("No widget found in the UI file.")
# Load the UI into a widget
loader = UILoader(None)
loaded = loader.loader(ui_file)
# Display the UI in a BECMainWindow
if isinstance(loaded, BECMainWindow):
window = loaded
window.object_name = filename
else:
window = BECMainWindow(object_name=filename)
window.setCentralWidget(loaded)
QApplication.processEvents()
window.setWindowTitle(f"BEC - {filename}")
window.show()
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
else:
auto_update = "auto_updates"
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
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():
tile.apply_theme(theme)
super().apply_theme(theme)
def _open_auto_update(self):
"""
Open the auto update window.
"""
if self.tiles["auto_update"].selector is None:
auto_update = None
else:
auto_update = self.tiles["auto_update"].selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_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):
"""
Open a file dialog to select a custom UI file and launch it.
"""
ui_file, _ = QFileDialog.getOpenFileName(
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
)
self.launch("custom_ui_file", ui_file=ui_file)
@staticmethod
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Load all available auto updates from the plugin repository.
"""
try:
auto_updates = get_plugin_auto_updates()
logger.info(f"Available auto updates: {auto_updates.keys()}")
except Exception as exc:
logger.error(f"Failed to load auto updates: {exc}")
return {}
return auto_updates
def show_launcher(self):
"""
Show the launcher window.
"""
self.show()
def hide_launcher(self):
"""
Hide the launcher window.
"""
self.hide()
def showEvent(self, event):
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
app = QApplication(sys.argv)
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1 @@
from .client import *

View File

@@ -0,0 +1,169 @@
# TODO autoupdate disabled
# from __future__ import annotations
#
# import threading
# from queue import Queue
# from typing import TYPE_CHECKING
#
# from pydantic import BaseModel
#
# if TYPE_CHECKING:
# from .client import BECDockArea, BECFigure
#
#
# class ScanInfo(BaseModel):
# scan_id: str
# scan_number: int
# scan_name: str
# scan_report_devices: list
# monitored_devices: list
# status: str
# model_config: dict = {"validate_assignment": True}
#
#
# class AutoUpdates:
# create_default_dock: bool = False
# enabled: bool = False
# dock_name: str = None
#
# def __init__(self, gui: BECDockArea):
# self.gui = gui
# self._default_dock = None
# self._default_fig = None
#
# def start_default_dock(self):
# """
# Create a default dock for the auto updates.
# """
# self.dock_name = "default_figure"
# self._default_dock = self.gui.new(self.dock_name)
# self._default_dock.new("BECFigure")
# self._default_fig = self._default_dock.elements_list[0]
#
# @staticmethod
# def get_scan_info(msg) -> ScanInfo:
# """
# Update the script with the given data.
# """
# info = msg.info
# status = msg.status
# scan_id = msg.scan_id
# scan_number = info.get("scan_number", 0)
# scan_name = info.get("scan_name", "Unknown")
# scan_report_devices = info.get("scan_report_devices", [])
# monitored_devices = info.get("readout_priority", {}).get("monitored", [])
# monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
# return ScanInfo(
# scan_id=scan_id,
# scan_number=scan_number,
# scan_name=scan_name,
# scan_report_devices=scan_report_devices,
# monitored_devices=monitored_devices,
# status=status,
# )
#
# def get_default_figure(self) -> BECFigure | None:
# """
# Get the default figure from the GUI.
# """
# return self._default_fig
#
# def do_update(self, msg):
# """
# Run the update function if enabled.
# """
# if not self.enabled:
# return
# if msg.status != "open":
# return
# info = self.get_scan_info(msg)
# return self.handler(info)
#
# def get_selected_device(self, monitored_devices, selected_device):
# """
# Get the selected device for the plot. If no device is selected, the first
# device in the monitored devices list is selected.
# """
# if selected_device:
# return selected_device
# if len(monitored_devices) > 0:
# sel_device = monitored_devices[0]
# return sel_device
# return None
#
# def handler(self, info: ScanInfo) -> None:
# """
# Default update function.
# """
# if info.scan_name == "line_scan" and info.scan_report_devices:
# return self.simple_line_scan(info)
# if info.scan_name == "grid_scan" and info.scan_report_devices:
# return self.simple_grid_scan(info)
# if info.scan_report_devices:
# return self.best_effort(info)
#
# def simple_line_scan(self, info: ScanInfo) -> None:
# """
# Simple line scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# selected_device = yield self.gui.selected_device
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
# if not dev_y:
# return
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# label=f"Scan {info.scan_number} - {dev_y}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )
#
# def simple_grid_scan(self, info: ScanInfo) -> None:
# """
# Simple grid scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# dev_y = info.scan_report_devices[1]
# selected_device = yield self.gui.selected_device
# dev_z = self.get_selected_device(info.monitored_devices, selected_device)
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# z_name=dev_z,
# label=f"Scan {info.scan_number} - {dev_z}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )
#
# def best_effort(self, info: ScanInfo) -> None:
# """
# Best effort scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# selected_device = yield self.gui.selected_device
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
# if not dev_y:
# return
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# label=f"Scan {info.scan_number} - {dev_y}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from typing import TYPE_CHECKING, Any
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
@@ -18,26 +18,17 @@ from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
from bec_lib.redis_connector import StreamMessage
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
logger = bec_logger.logger
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
str | bool | dict,
]
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
# pylint: disable=redefined-outer-scope
@@ -54,7 +45,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)
@@ -76,11 +67,7 @@ def _get_output(process, logger) -> None:
def _start_plot_process(
gui_id: str,
gui_class_id: str,
config: dict | str,
gui_class: str = "dock_area",
logger=None, # FIXME change gui_class back to "launcher" later
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
"""
Start the plot in a new process.
@@ -95,7 +82,7 @@ def _start_plot_process(
"--id",
gui_id,
"--gui_class",
gui_class,
gui_class.__name__,
"--gui_class_id",
gui_class_id,
"--hide",
@@ -154,10 +141,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:
@@ -210,28 +195,25 @@ class BECGuiClient(RPCBase):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._lock = Lock()
self._anchor_widget = "launcher"
self._default_dock_name = "bec"
self._auto_updates_enabled = True
self._auto_updates = None
self._killed = False
self._top_level: dict[str, RPCReference] = {}
self._top_level: dict[str, client.BECDockArea] = {}
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self._exposed_widgets = []
self._server_registry = {}
self._ipython_registry = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
####################
#### Client API ####
####################
@property
def launcher(self) -> RPCBase:
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
@@ -239,25 +221,21 @@ class BECGuiClient(RPCBase):
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
self._gui_id = gui_id
# reset the namespace
self._update_dynamic_namespace({})
self._server_registry = {}
self._top_level = {}
self._ipython_registry = {}
# Get the registry state
msgs = self._client.connector.xread(
MessageEndpoints.gui_registry_state(self._gui_id), count=1
)
if msgs:
self._handle_registry_update(msgs[0])
# Register the new callback
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
@property
def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return {widget.object_name: widget for widget in self._top_level.values()}
return self._top_level
@property
def window_list(self) -> list:
@@ -266,20 +244,13 @@ 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."""
@@ -290,8 +261,6 @@ class BECGuiClient(RPCBase):
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.
@@ -306,12 +275,14 @@ class BECGuiClient(RPCBase):
self.start(wait=True)
if wait:
with wait_for_server(self):
widget = self.launcher._run_rpc(
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_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
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget
@@ -381,29 +352,25 @@ class BECGuiClient(RPCBase):
# Wait for 'bec' gui to be registered, this may take some time
# After 60s timeout. Should this raise an exception on timeout?
while time.time() < time.time() + timeout:
if len(list(self._server_registry.keys())) < 2 or not hasattr(
self, self._anchor_widget
):
if len(list(self._server_registry.keys())) == 0:
time.sleep(0.1)
else:
break
self._do_show_all()
self._gui_started_event.set()
def _start_server(self, wait: bool = False) -> None:
"""
Start the GUI server, and execute callback when it is launched
"""
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
gui_class_id="bec",
self.__class__,
gui_class_id=self._default_dock_name,
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
@@ -413,7 +380,7 @@ class BECGuiClient(RPCBase):
if callable(callback):
callback()
finally:
threading.current_thread().cancel() # type: ignore
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
@@ -423,27 +390,25 @@ class BECGuiClient(RPCBase):
if wait:
self._gui_started_event.wait()
def _dump(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
return rpc_client._run_rpc("_dump")
def _start(self, wait: bool = False) -> None:
self._killed = False
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
return self._start_server(wait=wait)
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
def _handle_registry_update(self, msg: StreamMessage) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)
self._server_registry = msg["data"].state
self._update_dynamic_namespace()
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -454,72 +419,124 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", 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):
"""
Update the dynamic name space with the given server registry.
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
def _update_dynamic_namespace(self):
"""Update the dynamic name space"""
# Clear the top level
self._top_level.clear()
# First we update the name space based on the new registry state
self._add_registry_to_namespace()
# Then we clear the ipython registry from old objects
self._cleanup_ipython_registry()
Args:
server_registry (dict): The server registry
"""
top_level_widgets: dict[str, RPCReference] = {}
for gui_id, state in server_registry.items():
widget = self._add_widget(state, self)
if widget is None:
# ignore widgets that are not supported
continue
# get all top-level widgets. These are widgets that have no parent
if not state["config"].get("parent_id"):
top_level_widgets[gui_id] = widget
remove_from_registry = []
for gui_id, widget in self._ipython_registry.items():
if gui_id not in server_registry:
remove_from_registry.append(gui_id)
for gui_id in remove_from_registry:
self._ipython_registry.pop(gui_id)
removed_widgets = [
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
]
for widget_name in removed_widgets:
# the check is not strictly necessary, but better safe
# than sorry; who knows what the user has done
if hasattr(self, widget_name):
delattr(self, widget_name)
for gui_id, widget_ref in top_level_widgets.items():
setattr(self, widget_ref.object_name, widget_ref)
self._top_level = top_level_widgets
def _cleanup_ipython_registry(self):
"""Cleanup the ipython registry"""
names_in_registry = list(self._ipython_registry.keys())
names_in_server_state = list(self._server_registry.keys())
remove_ids = list(set(names_in_registry) - set(names_in_server_state))
for widget_id in remove_ids:
self._ipython_registry.pop(widget_id)
self._cleanup_rpc_references_on_rpc_base(remove_ids)
# Clear the exposed widgets
self._exposed_widgets.clear() # No longer needed I think
def _cleanup_rpc_references_on_rpc_base(self, remove_ids: list[str]) -> None:
"""Cleanup the rpc references on the RPCBase object"""
if not remove_ids:
return
for widget in self._ipython_registry.values():
widget._refresh_references()
to_delete = []
for attr_name, gui_id in widget._rpc_references.items():
if gui_id in remove_ids:
to_delete.append(attr_name)
for attr_name in to_delete:
if hasattr(widget, attr_name):
delattr(widget, attr_name)
if attr_name.startswith("elements."):
delattr(widget.elements, attr_name.split(".")[1])
widget._rpc_references.pop(attr_name)
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
def _set_dynamic_attributes(self, obj: object, name: str, value: Any) -> None:
"""Add an object to the namespace"""
setattr(obj, name, value)
def _update_rpc_references(self, widget: RPCBase, name: str, gui_id: str) -> None:
"""Update the RPC references"""
widget._rpc_references[name] = gui_id
def _add_registry_to_namespace(self) -> None:
"""Add registry to namespace"""
# Add dock areas
dock_area_states = [
state
for state in self._server_registry.values()
if state["widget_class"] == "BECDockArea"
]
for state in dock_area_states:
dock_area_ref = self._add_widget(state, self)
dock_area = self._ipython_registry.get(dock_area_ref._gui_id)
if not hasattr(dock_area, "elements"):
self._set_dynamic_attributes(dock_area, "elements", WidgetNameSpace())
self._set_dynamic_attributes(self, dock_area.widget_name, dock_area_ref)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(self, dock_area.widget_name, dock_area_ref._gui_id)
# Add dock_area to the top level
self._top_level[dock_area_ref.widget_name] = dock_area_ref
self._exposed_widgets.append(dock_area_ref._gui_id)
# Add docks
dock_states = [
state
for state in self._server_registry.values()
if state["config"].get("parent_id", "") == dock_area_ref._gui_id
]
for state in dock_states:
dock_ref = self._add_widget(state, dock_area)
dock = self._ipython_registry.get(dock_ref._gui_id)
self._set_dynamic_attributes(dock_area, dock_ref.widget_name, dock_ref)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(dock_area, dock_ref.widget_name, dock_ref._gui_id)
# Keep track of exposed docks
self._exposed_widgets.append(dock_ref._gui_id)
# Add widgets
widget_states = [
state
for state in self._server_registry.values()
if state["config"].get("parent_id", "") == dock_ref._gui_id
]
for state in widget_states:
widget_ref = self._add_widget(state, dock)
self._set_dynamic_attributes(dock, widget_ref.widget_name, widget_ref)
self._set_dynamic_attributes(
dock_area.elements, widget_ref.widget_name, widget_ref
)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(
dock_area, f"elements.{widget_ref.widget_name}", widget_ref._gui_id
)
self._update_rpc_references(dock, widget_ref.widget_name, widget_ref._gui_id)
# Keep track of exposed widgets
self._exposed_widgets.append(widget_ref._gui_id)
def _add_widget(self, state: dict, parent: object) -> RPCReference:
"""Add a widget to the namespace
Args:
state (dict): The state of the widget from the _server_registry.
parent (object): The parent object.
"""
object_name = state["object_name"]
name = state["name"]
gui_id = state["gui_id"]
if state["widget_class"] in IGNORE_WIDGETS:
return
widget_class = getattr(client, state["widget_class"], None)
if widget_class is None:
return
widget_class = getattr(client, state["widget_class"])
obj = self._ipython_registry.get(gui_id)
if obj is None:
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
widget = widget_class(gui_id=gui_id, name=name, parent=parent)
self._ipython_registry[gui_id] = widget
else:
widget = obj
@@ -539,7 +556,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

@@ -2,22 +2,17 @@
from __future__ import annotations
import argparse
import importlib
import inspect
import os
import sys
from pathlib import Path
import black
import isort
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger
if sys.version_info >= (3, 11):
from typing import get_overloads
else:
@@ -34,29 +29,13 @@ else:
class ClientGenerator:
def __init__(self, base=False):
self._base = base
base_imports = (
"""import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base
else "\n"
)
self.header = f"""# This file was automatically generated by generate_cli.py
# type: ignore \n
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
import enum
from typing import Literal, Optional, overload
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
# pylint: skip-file"""
@@ -83,7 +62,6 @@ logger = bec_logger.logger
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
logger.debug(f"generating RPC client class for {cls.__name__}")
self.content += "\n\n"
self.generate_content_for_class(cls)
@@ -91,50 +69,14 @@ logger = bec_logger.logger
"""
Write the client enum to the content.
"""
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
...
"""
self.content += """
_Widgets = {
class Widgets(str, enum.Enum):
\"\"\"
Enum for the available widgets.
\"\"\"
"""
for cls in published_classes:
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
self.content += """}
"""
if self._base:
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class
except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
"""
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
def generate_content_for_class(self, cls):
"""
@@ -180,10 +122,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 +147,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 +165,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)
@@ -261,78 +188,41 @@ def main():
"""
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
parser.add_argument(
"--target",
action="store",
type=str,
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
)
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
args = parser.parse_args()
if args.target is None:
logger.error(
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
)
return
logger.info(f"BEC Widget code generation tool started with args: {args}")
if args.core:
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
rpc_classes = get_custom_classes("bec_widgets")
try:
module = importlib.import_module(module_name)
assert module.__file__ is not None
module_file = Path(module.__file__)
module_dir = module_file.parent if module_file.is_file() else module_file
except Exception as e:
logger.error(f"Failed to load module {module_name} for code generation: {e}")
return
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
client_path = module_dir / client_subdir / "client.py"
for cls in rpc_classes.plugins:
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
rpc_classes = get_custom_classes(module_name)
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
generator = ClientGenerator(base=module_name == "bec_widgets")
logger.info(f"Generating client file at {client_path}")
generator.generate_client(rpc_classes)
generator.write(str(client_path))
if module_name != "bec_widgets":
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
logger.info(
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
)
else:
non_overwrite_classes = []
for cls in rpc_classes.plugins:
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
if cls.__name__ in non_overwrite_classes:
logger.error(
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
)
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
def _exists(file: str):
return os.path.exists(os.path.join(plugin.info.base_path, file))
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
logger.debug(
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
)
continue
plugin.run()
# if the class directory already has a register, plugin and pyproject file, skip
if os.path.exists(
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
):
continue
if os.path.exists(
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
):
continue
plugin.run()
if __name__ == "__main__": # pragma: no cover
import sys
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
sys.argv = ["generate_cli.py", "--core"]
main()

View File

@@ -4,64 +4,25 @@ import inspect
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
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
import bec_widgets.cli.client as client
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from bec_lib.connector import MessageObject
import bec_widgets.cli.client as client
from bec_widgets.cli.client_utils import BECGuiClient
else:
client = lazy_import("bec_widgets.cli.client") # avoid circular import
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
# pylint: disable=protected-access
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
# if dev.<device> is passed to GUI, it passes full_name
if hasattr(arg, "full_name"):
return arg.full_name
elif hasattr(arg, "name"):
return arg.name
return arg
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.
@@ -77,7 +38,7 @@ def rpc_call(func):
def wrapper(self, *args, **kwargs):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
caller_frame = inspect.currentframe().f_back # type: ignore
caller_frame = inspect.currentframe().f_back
while caller_frame:
if "jedi" in caller_frame.f_globals:
# Jedi module is present, likely tab completion
@@ -85,7 +46,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)
@@ -119,20 +88,26 @@ class RPCReference:
def __init__(self, registry: dict, gui_id: str) -> None:
self._registry = registry
self._gui_id = gui_id
self.object_name = self._registry[self._gui_id].object_name
@check_for_deleted_widget
def __getattr__(self, name):
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
if name in ["_registry", "_gui_id"]:
return super().__getattribute__(name)
return self._registry[self._gui_id].__getattribute__(name)
@check_for_deleted_widget
def __getitem__(self, key):
return self._registry[self._gui_id].__getitem__(key)
def __setattr__(self, name, value):
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__setattr__(name, value)
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
self._registry[self._gui_id].__setattr__(name, value)
if name in ["_registry", "_gui_id"]:
super().__setattr__(name, value)
else:
registry = super().__getattribute__("_registry")
gui_id = super().__getattribute__("_gui_id")
if gui_id not in registry:
raise DeletedWidgetError(f"Widget with gui_id {gui_id} has been deleted")
registry.__getitem__(gui_id).__setattr__(name, value)
def __repr__(self):
if self._gui_id not in self._registry:
@@ -149,23 +124,19 @@ class RPCReference:
return []
return self._registry[self._gui_id].__dir__()
def _is_deleted(self) -> bool:
return self._gui_id not in self._registry
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
object_name: str | None = None,
name: str | None = None,
parent=None,
**kwargs,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
self._name = name if name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
@@ -175,23 +146,23 @@ 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 _root(self) -> BECGuiClient:
def widget_name(self):
"""
Get the widget name.
"""
return self._name
@property
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
@@ -200,17 +171,9 @@ class RPCBase:
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent # type: ignore
return parent
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=3, **kwargs) -> Any:
"""
Run the RPC call.
@@ -218,8 +181,6 @@ 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:
@@ -228,7 +189,7 @@ class RPCBase:
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
@@ -254,11 +215,7 @@ class RPCBase:
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# we can assume that the response is a RequestResponseMessage, updated by
# the _on_rpc_response method
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
@@ -266,10 +223,10 @@ class RPCBase:
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -289,14 +246,13 @@ class RPCBase:
cls = getattr(client, cls)
# The namespace of the object will be updated dynamically on the client side
# Therefore it is important to check if the object is already in the registry
# Therefor it is important to check if the object is already in the registry
# If yes, we return the reference to the object, otherwise we create a new object
# pylint: disable=protected-access
if msg_result["gui_id"] in self._root._ipython_registry:
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
ret = cls(parent=self, **msg_result)
self._root._ipython_registry[ret._gui_id] = ret
self._refresh_references()
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
return obj
# return ret
@@ -312,27 +268,3 @@ class RPCBase:
if heart.status == messages.BECStatus.RUNNING:
return True
return False
def _refresh_references(self):
"""
Refresh the references.
"""
with self._root._lock:
references = {}
for key, val in self._root._server_registry.items():
parent_id = val["config"].get("parent_id")
if parent_id == self._gui_id:
references[key] = {
"gui_id": val["config"]["gui_id"],
"object_name": val["object_name"],
}
removed_references = set(self._rpc_references.keys()) - set(references.keys())
for key in removed_references:
delattr(self, self._rpc_references[key]["object_name"])
self._rpc_references = references
for key, val in references.items():
setattr(
self,
val["object_name"],
RPCReference(self._root._ipython_registry, val["gui_id"]),
)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import wraps
from threading import RLock
from threading import Lock, RLock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
@@ -65,7 +65,7 @@ class RPCRegister:
return register._broadcast_on_hold
@broadcast_update
def add_rpc(self, rpc: BECConnector):
def add_rpc(self, rpc: QObject):
"""
Add an RPC object to the register.
@@ -77,7 +77,7 @@ class RPCRegister:
self._rpc_register[rpc.gui_id] = rpc
@broadcast_update
def remove_rpc(self, rpc: BECConnector):
def remove_rpc(self, rpc: str):
"""
Remove an RPC object from the register.
@@ -113,7 +113,7 @@ class RPCRegister:
return connections
def get_names_of_rpc_by_class_type(
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
) -> list[str]:
"""Get all the names of the widgets.
@@ -123,7 +123,7 @@ class RPCRegister:
# This retrieves any rpc objects that are subclass of BECWidget,
# i.e. curve and image items are excluded
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
return [widget.object_name for widget in widgets]
return [widget._name for widget in widgets]
def broadcast(self):
"""
@@ -136,18 +136,6 @@ class RPCRegister:
for callback in self.callbacks:
callback(connections)
def object_is_registered(self, obj: BECConnector) -> bool:
"""
Check if an object is registered in the RPC register.
Args:
obj(QObject): The object to check.
Returns:
bool: True if the object is registered, False otherwise.
"""
return obj.gui_id in self._rpc_register
def add_callback(self, callback: Callable[[dict], None]):
"""
Add a callback that will be called whenever the registry is updated.
@@ -182,8 +170,7 @@ class RPCRegisterBroadcast:
def __exit__(self, *exc):
"""Exit the context manager"""
self._call_depth -= 1 # Remove nested calls
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
if self._call_depth == 0: # Last one to exit is repsonsible for broadcasting
self.rpc_register._skip_broadcast = False
self.rpc_register.broadcast()

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import get_custom_classes
class RPCWidgetHandler:
@@ -31,11 +31,14 @@ class RPCWidgetHandler:
Returns:
None
"""
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
from bec_widgets.utils.plugin_utils import get_custom_classes
def create_widget(self, widget_type, **kwargs) -> BECWidget:
clss = get_custom_classes("bec_widgets")
self._widget_classes = {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
"""
Create a widget from an RPC message.
@@ -49,7 +52,7 @@ class RPCWidgetHandler:
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(**kwargs)
return widget_class(name=name, **kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -1,29 +1,188 @@
from __future__ import annotations
import argparse
import functools
import json
import os
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union
import darkdetect
from bec_lib.endpoints import MessageEndpoints
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
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher
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.dock import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class BECWidgetsCLIServer:
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: type[BECDockArea] = BECDockArea,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
with RPCRegister.delayed_broadcast():
self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
logger.error(f"Error while executing RPC instruction: {e}")
self.send_response(request_id, False, {"error": str(e)})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
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
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj):
if isinstance(obj, BECConnector):
config = obj.config.model_dump()
config["parent_id"] = obj.parent_id # add parent_id to config
return {
"gui_id": obj.gui_id,
"name": (
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
), # pylint: disable=protected-access
"widget_class": obj.__class__.__name__,
"config": config,
"__rpc__": True,
}
return obj
def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict):
"""
Broadcast the updated registry to all clients.
"""
# We only need to broadcast the dock areas
data = {key: self.serialize_object(val) for key, val in connections.items()}
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1, # only single message in stream
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down gui")
self.client.shutdown()
class SimpleFileLikeFromLogOutputFunc:
@@ -40,127 +199,44 @@ class SimpleFileLikeFromLogOutputFunc:
self._log_func(lines)
self._buffer = [remaining]
@property
def encoding(self):
return "utf-8"
def close(self):
return
class GUIServer:
"""
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
"""
def _start_server(
gui_id: str, gui_class: BECDockArea, gui_class_id: str = "bec", config: str | None = None
):
if config:
try:
config = json.loads(config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# if no config is provided, use the default config
service_config = ServiceConfig()
def __init__(self, args):
self.config = args.config
self.gui_id = args.id
self.gui_class = args.gui_class
self.gui_class_id = args.gui_class_id
self.hide = args.hide
self.app: QApplication | None = None
self.launcher_window: LaunchWindow | None = None
self.dispatcher: BECDispatcher | None = None
def start(self):
"""
Start the GUI server.
"""
bec_logger.level = bec_logger.LOGLEVEL.INFO
if self.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
self._run()
def _get_service_config(self) -> ServiceConfig:
if self.config:
try:
config = json.loads(self.config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# if no config is provided, use the default config
service_config = ServiceConfig()
return service_config
def _run(self):
"""
Run the GUI server.
"""
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()
service_config = self._get_service_config()
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
widget.close()
if self.app:
self.app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(self.app.exec())
def setup_bec_icon(self):
"""
Set the BEC icon for the application
"""
if self.app is None:
return
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
def shutdown(self):
"""
Shutdown the GUI server.
"""
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()
# bec_logger.configure(
# service_config.redis,
# QtRedisConnector,
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
)
return server
def main():
"""
Main entry point for subprocesses that start a GUI server.
"""
import argparse
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, default="test", help="The id of the server")
@@ -180,12 +256,69 @@ def main():
args = parser.parse_args()
server = GUIServer(args)
server.start()
bec_logger.level = bec_logger.LOGLEVEL.INFO
if args.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
if args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECDockArea
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
# set close on last window, only if not under control of client ;
# indeed, Qt considers a hidden window a closed window, so if all windows
# are hidden by default it exits
app.setQuitOnLastWindowClosed(not args.hide)
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
# store gui id within QApplication object, to make it available to all widgets
app.gui_id = args.id
# args.id = "abff6"
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC")
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
if not args.hide:
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets():
widget.close()
app.quit()
# gui.bec.close()
# win.shutdown()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(app.exec())
if __name__ == "__main__":
# import sys
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
main()

View File

@@ -15,9 +15,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
@@ -46,22 +44,21 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"wh": wh,
"dock": self.dock,
"im": self.im,
"ads": self.ads,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
# "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,
"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,
}
)
@@ -80,74 +77,76 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
# 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")
#
# 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")
#
# tab_widget.setCurrentIndex(3)
#
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")
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")
tab_widget.setCurrentIndex(3)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
#
# # 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")
#
# 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)
#
# 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")
fifth_tab = QWidget()
fifth_tab_layout = QVBoxLayout(fifth_tab)
self.wf = Waveform()
fifth_tab_layout.addWidget(self.wf)
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
tab_widget.setCurrentIndex(4)
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image(popups=True)
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(1)
#
tab_widget.setCurrentIndex(5)
seventh_tab = QWidget()
seventh_tab_layout = QVBoxLayout(seventh_tab)
self.ads = AdvancedDockArea(gui_id="ads")
seventh_tab_layout.addWidget(self.ads)
tab_widget.addTab(seventh_tab, "ADS")
tab_widget.setCurrentIndex(2)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# 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)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
#
# self.setWindowTitle("Jupyter Console Window")
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)
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)
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)
# add stuff to the new Waveform widget
self._init_waveform()
self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
@@ -170,13 +169,12 @@ 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(gui_id="jupyter_console")
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()

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 = {
@@ -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
@@ -11,21 +10,21 @@ from typing import TYPE_CHECKING, Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.dock import BECDock
else:
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
class ConnectionConfig(BaseModel):
@@ -77,55 +76,25 @@ class BECConnector:
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
widget_removed = Signal()
name_established = Signal(str)
def __init__(
self,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
root_widget: bool = False,
**kwargs,
name: str | None = None,
parent_dock: BECDock | None = None,
parent_id: str | None = None,
):
"""
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
parent = kwargs.pop("parent", None)
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
super().__init__(parent=parent, **kwargs)
assert isinstance(
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# flag to check if the object was destroyed and its cleanup was called
self._destroyed = False
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
self.rpc_register = RPCRegister()
self._parent_dock = parent_dock
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()
@@ -145,6 +114,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:
@@ -152,17 +122,14 @@ class BECConnector:
self.gui_id: str = gui_id # Keep namespace in sync
else:
self.gui_id: str = self.config.gui_id # type: ignore
if object_name is not None:
self.setObjectName(object_name)
# 1) If no objectName is set, set the initial name
if not self.objectName():
self.setObjectName(self.__class__.__name__)
self.object_name = self.objectName()
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if name is None:
name = self.__class__.__name__
else:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -171,104 +138,7 @@ 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:
"""
Enforce a unique object name among siblings and register the object for RPC.
This method is called through a single shot timer kicked off in the constructor.
"""
# 1) Enforce unique objectName among siblings with the same BECConnector parent
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
try:
self.name_established.emit(self.object_name)
except RuntimeError:
return
def _enforce_unique_sibling_name(self):
"""
Enforce that this BECConnector has a unique objectName among its siblings.
Sibling logic:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
siblings = parent_bec.findChildren(BECConnector)
else:
# No parent => treat all top-level BECConnectors as siblings
# 1) Gather all BECConnectors from QApplication
all_widgets = QApplication.allWidgets()
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
# 2) "Top-level" means closest BECConnector parent is None
top_level_bec = [
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
]
# 3) We are among these top-level siblings
siblings = top_level_bec
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
base_name = self.object_name
if base_name not in used_names:
# Name is already unique among siblings
return
# Need a suffix to avoid collision
counter = 0
while True:
trial_name = f"{base_name}_{counter}"
if trial_name not in used_names:
self.setObjectName(trial_name)
self.object_name = trial_name
break
counter += 1
# pylint: disable=invalid-name
def setObjectName(self, name: str) -> None:
"""
Set the object name of the widget.
Args:
name (str): The new object name.
"""
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
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.
@@ -396,7 +266,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.
@@ -428,7 +298,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.
@@ -446,9 +316,8 @@ class BECConnector:
def remove(self):
"""Cleanup the BECConnector"""
# If the widget is attached to a dock, remove it from the dock.
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
if self._parent_dock is not None:
self._parent_dock.delete(self.object_name)
self._parent_dock.delete(self._name)
# If the widget is from Qt, trigger its close method.
elif hasattr(self, "close"):
self.close()
@@ -456,7 +325,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

@@ -10,15 +10,13 @@ from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
@@ -78,7 +76,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 +117,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,24 +145,15 @@ 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
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
set_plugin_environment_variable(plugin_paths)
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
patch_designer()
if __name__ == "__main__": # pragma: no cover

View File

@@ -1,12 +1,9 @@
from __future__ import annotations
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 +12,28 @@ 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)
@@ -100,29 +73,18 @@ class BECDispatcher:
_instance = None
_initialized = False
client: BECClient
cli_server: RPCServer | None = None
def __new__(
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str | None = None,
*args,
**kwargs,
):
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
def __init__(self, client=None, config: str | ServiceConfig = None):
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,26 +107,18 @@ 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)
self._initialized = True
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance of the BECDispatcher.
"""
cls._instance = None
cls._initialized = False
def connect_slot(
self,
slot: Callable,
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
cb_info: dict | None = None,
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 +126,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 +164,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):
"""
@@ -235,49 +178,4 @@ class BECDispatcher:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
# pylint: disable=protected-access
self.disconnect_topics(self.client.connector._topics_cb)
def start_cli_server(self, gui_id: str | None = None):
"""
Start the CLI server.
Args:
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
"""
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.rpc_server import RPCServer
if gui_id is None:
gui_id = self.generate_unique_identifier()
if not self.client.started:
logger.error("Cannot start CLI server without a running client")
return
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
logger.success(f"Started CLI server with gui_id: {gui_id}")
def stop_cli_server(self):
"""
Stop the CLI server.
"""
if self.cli_server is None:
logger.error("Cannot stop CLI server without starting it first")
return
self.cli_server.shutdown()
self.cli_server = None
logger.success("Stopped CLI server")
@staticmethod
def generate_unique_identifier(length: int = 4) -> str:
"""
Generate a unique identifier for the application.
Args:
length: The length of the identifier. Defaults to 4.
Returns:
str: The unique identifier.
"""
allowed_chars = string.ascii_lowercase + string.digits
return "".join(random.choices(allowed_chars, k=length))

View File

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

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,19 +1,15 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
import PySide6QtAds as QtAds
import shiboken6
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication, QWidget
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 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
@@ -27,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__(
@@ -36,7 +32,9 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
name: str | None = None,
parent_dock: BECDock | None = None,
parent_id: str | None = None,
**kwargs,
):
"""
@@ -45,7 +43,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:
@@ -55,12 +54,25 @@ 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.
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
client=client,
config=config,
gui_id=gui_id,
name=name,
parent_dock=parent_dock,
parent_id=parent_id,
)
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__}")
@@ -69,11 +81,9 @@ class BECWidget(BECConnector):
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
qapp.theme.theme_changed.connect(self._update_theme)
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
# @SafeSlot(str, verify_sender=True)
# @SafeSlot(verify_sender=True)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -84,7 +94,7 @@ class BECWidget(BECConnector):
theme = "dark"
self.apply_theme(theme)
@SafeSlot(str)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
@@ -93,69 +103,15 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def 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()
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,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

@@ -3,29 +3,24 @@ from __future__ import annotations
import re
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._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from bec_qthemes._main import AccentColors
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
else:
return QApplication.instance().theme.theme
def get_theme_palette():
# FIXME this is legacy code, should be removed in the future
app = QApplication.instance()
palette = app.palette()
return palette
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 | None:
@@ -38,11 +33,105 @@ def get_accent_colors() -> AccentColors | None:
return QApplication.instance().theme.accent_colors
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
if theme != "auto":
return
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def 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.
"""
apply_theme_global(theme)
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)
)
@@ -259,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.
@@ -260,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
@@ -348,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(
@@ -410,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.
@@ -507,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))
@@ -519,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():
@@ -591,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)
@@ -629,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

@@ -96,55 +96,23 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
'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", False)),
}
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 Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
@@ -14,20 +12,15 @@ 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):
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
@@ -36,33 +29,19 @@ class ExpandableGroupFrame(QFrame):
self._layout = QVBoxLayout()
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._title = ClickableLabel(f"<b>{title}</b>")
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
@@ -71,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
@@ -83,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
@@ -93,36 +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)
# 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,282 +0,0 @@
from __future__ import annotations
from types import NoneType
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
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 = QWidget(parent=self._form_grid_container)
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
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(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, 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):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
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()
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()
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

View File

@@ -1,624 +0,0 @@
from __future__ import annotations
import typing
from abc import abstractmethod
from decimal import Decimal
from types import GenericAlias, UnionType
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
from 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
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()
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
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:
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
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._main_widget.clear) # type: ignore
def _value_changed(self, *_, **__):
self.valueChanged.emit()
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)
class _ItemAndWidgetType(NamedTuple):
# TODO: this should be generic but not supported in 3.10
item: type[int | float | str]
widget: type[QWidget]
default: int | float | str
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() * 6)
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 _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)
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 _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
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):
return self._main_widget.currentText()
def setValue(self, value: str | None):
if value is None:
self.clear()
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)
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
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: type(spec.info.annotation) is type(Literal[""])
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
StrLiteralFormItem,
),
"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):
return widget_type
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()
app = QApplication([])
w = QWidget()
layout = QGridLayout()
w.setLayout(layout)
items = []
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
spec = 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

@@ -1,33 +1,17 @@
import inspect
import os
import re
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)
class PluginFilenames(NamedTuple):
register: str
plugin: str
pyproj: str
def plugin_filenames(name: str) -> PluginFilenames:
return PluginFilenames(f"register_{name}.py", f"{name}_plugin.py", f"{name}.pyproject")
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
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 +27,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):
@@ -54,15 +53,11 @@ class DesignerPluginGenerator:
self._excluded = True
return
self.templates: dict[str, str] = {}
self.templates = {}
self.template_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
)
@property
def filenames(self):
return plugin_filenames(self.info.plugin_name_snake)
def run(self, validate=True):
if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.")
@@ -93,38 +88,50 @@ 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(
f"Widget class {self.widget.__name__} must call the super constructor with parent."
)
def _write_file(self, name: str, contents: str):
with open(os.path.join(self.info.base_path, name), "w", encoding="utf-8") as f:
f.write(contents)
def _format(self, name: str):
return self.templates[name].format(**self.info.__dict__)
def _write_templates(self):
self._write_file(self.filenames.register, self._format("register"))
self._write_file(self.filenames.plugin, self._format("plugin"))
pyproj = str({"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]})
self._write_file(self.filenames.pyproj, pyproj)
self._write_register()
self._write_plugin()
self._write_pyproject()
def _write_register(self):
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.templates["register"].format(**self.info.__dict__))
def _write_plugin(self):
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.templates["plugin"].format(**self.info.__dict__))
def _write_pyproject(self):
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
with open(file_path, "w", encoding="utf-8") as f:
f.write(str(out))
def _load_templates(self):
for file in os.listdir(self.template_path):

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

@@ -22,10 +22,10 @@ class PaletteViewer(BECWidget, QWidget):
"""
ICON_NAME = "palette"
RPC = False
def __init__(self, *args, parent=None, **kwargs):
super().__init__(parent=parent, theme_update=True, **kwargs)
super().__init__(*args, theme_update=True, **kwargs)
QWidget.__init__(self, parent=parent)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)

View File

@@ -2,7 +2,6 @@
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
{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

@@ -1,10 +1,7 @@
from __future__ import annotations
import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -12,9 +9,6 @@ from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
@@ -51,70 +45,20 @@ def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
specified in the respective pyproject.toml file using the following key:
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
e.g.
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {}
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated auto update {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_auto_updates(obj):
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
return (
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
)
@dataclass
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 __repr__(self):
return str(list(cl.name for cl in self.collection))
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 __init__(self):
self._collection = []
def add_class(self, class_info: BECClassInfo):
"""
@@ -125,44 +69,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 +151,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.
@@ -17,7 +16,6 @@ class RoundedFrame(QFrame):
parent=None,
content_widget: QWidget = None,
background_color: str = None,
orientation: str = "horizontal",
radius: int = 10,
):
QFrame.__init__(self, parent)
@@ -29,16 +27,9 @@ class RoundedFrame(QFrame):
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Ensure QSS can paint background/border on this widget
self.setAttribute(Qt.WA_StyledBackground, True)
# Create a layout for the frame
if orientation == "vertical":
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
else:
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
@@ -49,10 +40,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 +72,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 +123,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,277 +0,0 @@
from __future__ import annotations
import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, TypeVar
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 QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
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
from bec_lib import messages
from qtpy.QtCore import QObject
else:
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
T = TypeVar("T")
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class RPCServer:
client: BECClient
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher | None = None,
client: BECClient | None = None,
config=None,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
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}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
if request_id is None:
logger.error("Received RPC instruction without request_id")
return
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception:
content = traceback.format_exc()
logger.error(f"Error while executing RPC instruction: {content}")
self.send_response(request_id, False, {"error": content})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
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
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj: T) -> None | dict | T:
"""
Serialize all BECConnector objects.
Args:
obj: The object to be serialized.
Returns:
None | dict | T: The serialized object or None if the object is not a BECConnector.
"""
if not isinstance(obj, BECConnector):
return obj
# Respect RPC = False
if getattr(obj, "RPC", True) is False:
return None
return self._serialize_bec_connector(obj, wait=True)
def emit_heartbeat(self) -> None:
"""
Emit a heartbeat message to the GUI server.
This method is called periodically to indicate that the server is still running.
"""
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict) -> None:
"""
Broadcast the registry update to all the callbacks.
This method is called whenever the registry is updated.
"""
data = {}
for key, val in connections.items():
if not isinstance(val, BECConnector):
continue
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}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
"""
Create the serialization dict for a single BECConnector.
Args:
connector (BECConnector): The BECConnector to serialize.
wait (bool): If True, wait until the object is registered in the RPC register.
Returns:
dict: The serialized BECConnector object.
"""
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()
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
widget_class = connector.__class__.__name__
return {
"gui_id": connector.gui_id,
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"container_proxy": container_proxy,
"__rpc__": True,
}
@staticmethod
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
# Suppose clients register callbacks to receive updates
def add_registry_update_callback(self, cb: Callable) -> None:
"""
Add a callback to be called whenever the registry is updated.
The specified callback is called whenever the registry is updated.
Args:
cb (Callable): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self._registry_update_callbacks.append(cb)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -1,34 +0,0 @@
from typing import Type
from bec_lib.codecs import BECCodec
from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF
def register_serializer_extension():
"""
Register the serializer extension for the BECConnector.
"""
if not msgpack.is_registered(QPointF):
msgpack.register_codec(QPointFEncoder)
class QPointFEncoder(BECCodec):
obj_type: Type = QPointF
@staticmethod
def encode(obj: QPointF) -> str:
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
@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

View File

@@ -1,11 +1,7 @@
from bec_lib.logger import bec_logger
from qtpy.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class SettingWidget(QWidget):
"""
@@ -41,15 +37,6 @@ class SettingWidget(QWidget):
"""
pass
def cleanup(self):
"""
Cleanup the settings widget.
"""
def closeEvent(self, event: QCloseEvent) -> None:
self.cleanup()
return super().closeEvent(event)
class SettingsDialog(QDialog):
"""
@@ -112,17 +99,8 @@ class SettingsDialog(QDialog):
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
self.cleanup()
super().accept()
@SafeSlot()
def reject(self):
"""
Reject the changes made in the settings widget and close the dialog.
"""
self.cleanup()
super().reject()
@SafeSlot()
def apply_changes(self):
"""
@@ -136,10 +114,7 @@ class SettingsDialog(QDialog):
"""
self.button_box.close()
self.button_box.deleteLater()
self.widget.close()
self.widget.deleteLater()
def closeEvent(self, event):
logger.info("Closing settings dialog")
self.cleanup()
super().closeEvent(event)

View File

@@ -16,8 +16,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
class SidePanel(QWidget):
@@ -32,17 +31,16 @@ class SidePanel(QWidget):
panel_max_width: int = 200,
animation_duration: int = 200,
animations_enabled: bool = True,
show_toolbar: bool = True,
):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("SidePanel")
self._orientation = orientation
self._panel_max_width = panel_max_width
self._animation_duration = animation_duration
self._animations_enabled = animations_enabled
self._show_toolbar = show_toolbar
self._panel_width = 0
self._panel_height = 0
@@ -62,7 +60,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, orientation="vertical")
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -74,14 +72,13 @@ class SidePanel(QWidget):
self.stack_widget.setMinimumWidth(5)
self.stack_widget.setMaximumWidth(self._panel_max_width)
if self._orientation in ("left", "right"):
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
if self._orientation == "left":
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
self.main_layout.addWidget(self.toolbar)
if self._orientation == "left":
self.main_layout.addWidget(self.container)
else:
self.main_layout.insertWidget(0, self.container)
self.container.layout.addWidget(self.stack_widget)
self.menu_anim = QPropertyAnimation(self, b"panel_width")
@@ -93,7 +90,7 @@ class SidePanel(QWidget):
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -106,13 +103,11 @@ class SidePanel(QWidget):
self.stack_widget.setMaximumHeight(self._panel_max_width)
if self._orientation == "top":
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.container)
else:
self.main_layout.addWidget(self.container)
if self._show_toolbar:
self.main_layout.addWidget(self.toolbar)
self.main_layout.addWidget(self.toolbar)
self.container.layout.addWidget(self.stack_widget)
@@ -239,24 +234,21 @@ class SidePanel(QWidget):
def add_menu(
self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget,
action_id: str | None = None,
icon_name: str | None = None,
tooltip: str | None = None,
title: str | None = None,
) -> int:
):
"""
Add a menu to the side panel.
Args:
action_id(str): The ID of the action.
icon_name(str): The name of the icon.
tooltip(str): The tooltip for the action.
widget(QWidget): The widget to add to the panel.
action_id(str | None): The ID of the action. Optional if no toolbar action is needed.
icon_name(str | None): The name of the icon. Optional if no toolbar action is needed.
tooltip(str | None): The tooltip for the action. Optional if no toolbar action is needed.
title(str | None): The title of the panel.
Returns:
int: The index of the added panel, which can be used with show_panel() and switch_to().
title(str): The title of the panel.
"""
# container_widget: top-level container for the stacked page
container_widget = QWidget()
@@ -287,43 +279,32 @@ class SidePanel(QWidget):
index = self.stack_widget.count()
self.stack_widget.addWidget(container_widget)
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
if action_id is not None and icon_name is not None and tooltip is not None:
action = MaterialIconAction(
icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self
)
self.toolbar.components.add_safe(action_id, action)
bundle = ToolbarBundle(action_id, self.toolbar.components)
bundle.add_action(action_id)
self.toolbar.add_bundle(bundle)
shown_bundles = self.toolbar.shown_bundles
shown_bundles.append(action_id)
self.toolbar.show_bundles(shown_bundles)
# Add an action to the toolbar
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
self.toolbar.add_action(action_id, action, target_widget=self)
def on_action_toggled(checked: bool):
if self.switching_actions:
return
def on_action_toggled(checked: bool):
if self.switching_actions:
return
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
if checked:
if self.current_action and self.current_action != action.action:
self.switching_actions = True
self.current_action.setChecked(False)
self.switching_actions = False
self.current_action = action.action
self.current_action = action.action
if not self.panel_visible:
self.show_panel(index)
else:
self.switch_to(index)
if not self.panel_visible:
self.show_panel(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
self.switch_to(index)
else:
if self.current_action == action.action:
self.current_action = None
self.hide_panel()
action.action.toggled.connect(on_action_toggled)
return index
action.action.toggled.connect(on_action_toggled)
############################################
@@ -352,56 +333,41 @@ class ExampleApp(QMainWindow): # pragma: no cover
self.add_side_menus()
def add_side_menus(self):
# Example 1: With action, icon, and tooltip
widget1 = QWidget()
layout1 = QVBoxLayout(widget1)
for i in range(15):
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
self.side_panel.add_menu(
widget=widget1,
action_id="widget1",
icon_name="counter_1",
tooltip="Show Widget 1",
widget=widget1,
title="Widget 1 Panel",
)
# Example 2: With action, icon, and tooltip
widget2 = QWidget()
layout2 = QVBoxLayout(widget2)
layout2.addWidget(QLabel("Short widget 2 content"))
self.side_panel.add_menu(
widget=widget2,
action_id="widget2",
icon_name="counter_2",
tooltip="Show Widget 2",
widget=widget2,
title="Widget 2 Panel",
)
# Example 3: With action, icon, and tooltip
widget3 = QWidget()
layout3 = QVBoxLayout(widget3)
for i in range(10):
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
self.side_panel.add_menu(
widget=widget3,
action_id="widget3",
icon_name="counter_3",
tooltip="Show Widget 3",
widget=widget3,
title="Widget 3 Panel",
)
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
widget4 = QWidget()
layout4 = QVBoxLayout(widget4)
layout4.addWidget(QLabel("This panel has no toolbar button"))
layout4.addWidget(QLabel("It can only be shown programmatically"))
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
# Example of how to show the hidden panel programmatically after 3 seconds
from qtpy.QtCore import QTimer
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

1000
bec_widgets/utils/toolbar.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,526 +0,0 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Dict, Literal
from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtGui import QAction, QColor, QIcon
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QMenu,
QSizePolicy,
QStyledItemDelegate,
QToolBar,
QToolButton,
QWidget,
)
import bec_widgets
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
class LongPressToolButton(QToolButton):
def __init__(self, *args, long_press_threshold=500, **kwargs):
super().__init__(*args, **kwargs)
self.long_press_threshold = long_press_threshold
self._long_press_timer = QTimer(self)
self._long_press_timer.setSingleShot(True)
self._long_press_timer.timeout.connect(self.handleLongPress)
self._pressed = False
self._longPressed = False
def mousePressEvent(self, event):
self._pressed = True
self._longPressed = False
self._long_press_timer.start(self.long_press_threshold)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
self._pressed = False
if self._longPressed:
self._longPressed = False
self._long_press_timer.stop()
event.accept() # Prevent normal click action after a long press
return
self._long_press_timer.stop()
super().mouseReleaseEvent(event)
def handleLongPress(self):
if self._pressed:
self._longPressed = True
self.showMenu()
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
Args:
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
"""
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
self.icon_path = (
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
)
self.tooltip = tooltip
self.checkable = checkable
self.action = None
@abstractmethod
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""Adds an action or widget to a toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action or widget to.
target (QWidget): The target widget for the action.
"""
def cleanup(self):
"""Cleans up the action, if necessary."""
pass
class SeparatorAction(ToolBarAction):
"""Separator action for the toolbar."""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
toolbar.addSeparator()
class QtIconAction(ToolBarAction):
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.standard_icon = standard_icon
self.icon = QApplication.style().standardIcon(standard_icon)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar, target):
toolbar.addAction(self.action)
def get_icon(self):
return self.icon
class MaterialIconAction(ToolBarAction):
"""
Action with a Material icon for the toolbar.
Args:
icon_name (str, optional): The name of the Material icon. Defaults to None.
tooltip (str, optional): The tooltip for the action. Defaults to None.
checkable (bool, optional): Whether the action is checkable. Defaults to False.
filled (bool, optional): Whether the icon is filled. Defaults to False.
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
Defaults to None.
parent (QWidget or None, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
icon_name: str = None,
tooltip: str = None,
checkable: bool = False,
filled: bool = False,
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.icon_name = icon_name
self.filled = filled
self.color = color
# Generate the icon using the material_icon helper
self.icon = material_icon(
self.icon_name,
size=(20, 20),
convert_to_pixmap=False,
filled=self.filled,
color=self.color,
)
if parent is None:
logger.warning(
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
)
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
self.action.setCheckable(self.checkable)
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
toolbar.addAction(self.action)
def get_icon(self):
"""
Returns the icon for the action.
Returns:
QIcon: The icon for the action.
"""
return self.icon
class DeviceSelectionAction(ToolBarAction):
"""
Action for selecting a device in a combobox.
Args:
label (str): The label for the combobox.
device_combobox (DeviceComboBox): The combobox for selecting the device.
"""
def __init__(self, label: str | None = None, device_combobox=None):
super().__init__()
self.label = label
self.device_combobox = device_combobox
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
def add_to_toolbar(self, toolbar, target):
widget = QWidget(parent=target)
layout = QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label = QLabel(text=f"{self.label}", parent=target)
layout.addWidget(label)
if self.device_combobox is not None:
layout.addWidget(self.device_combobox)
toolbar.addWidget(widget)
def set_combobox_style(self, color: str):
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
class SwitchableToolBarAction(ToolBarAction):
"""
A split toolbar action that combines a main action and a drop-down menu for additional actions.
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
alternative action is selected, it becomes the new default and its callback is immediately executed.
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
Args:
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
checkable (bool, optional): Whether the action is checkable. Defaults to True.
parent (QWidget, optional): Parent widget for the underlying QAction.
"""
def __init__(
self,
actions: Dict[str, ToolBarAction],
initial_action: str = None,
tooltip: str = None,
checkable: bool = True,
default_state_checked: bool = False,
parent=None,
):
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
self.actions = actions
self.current_key = initial_action if initial_action is not None else next(iter(actions))
self.parent = parent
self.checkable = checkable
self.default_state_checked = default_state_checked
self.main_button = None
self.menu_actions: Dict[str, QAction] = {}
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the split action to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the action to.
target (QWidget): The target widget for the action.
"""
self.main_button = LongPressToolButton(toolbar)
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
self.main_button.setCheckable(self.checkable)
default_action = self.actions[self.current_key]
self.main_button.setIcon(default_action.get_icon())
self.main_button.setToolTip(default_action.tooltip)
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
for key, action_obj in self.actions.items():
menu_action = QAction(
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
)
menu_action.setIconVisibleInMenu(True)
menu_action.setCheckable(self.checkable)
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.main_button.setMenu(menu)
if self.default_state_checked:
self.main_button.setChecked(True)
self.action = toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
"""
Triggers the current action associated with the main button.
"""
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
"""
Sets the default action for the split action.
Args:
key(str): The key of the action to set as default.
"""
if self.main_button is None:
return
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.actions.items():
menu_act.action.setChecked(False)
new_action.action.trigger()
# Active action chosen from menu is always checked, uncheck through main button
if self.checkable:
new_action.action.setChecked(True)
self.main_button.setChecked(True)
def block_all_signals(self, block: bool = True):
"""
Blocks or unblocks all signals for the actions in the toolbar.
Args:
block (bool): Whether to block signals. Defaults to True.
"""
if self.main_button is not None:
self.main_button.blockSignals(block)
for action in self.actions.values():
action.action.blockSignals(block)
@contextmanager
def signal_blocker(self):
"""
Context manager to block signals for all actions in the toolbar.
"""
self.block_all_signals(True)
try:
yield
finally:
self.block_all_signals(False)
def set_state_all(self, state: bool):
"""
Uncheck all actions in the toolbar.
"""
for action in self.actions.values():
action.action.setChecked(state)
if self.main_button is None:
return
self.main_button.setChecked(state)
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
class WidgetAction(ToolBarAction):
"""
Action for adding any widget to the toolbar.
Please note that the injected widget must be life-cycled by the parent widget,
i.e., the widget must be properly cleaned up outside of this action. The WidgetAction
will not perform any cleanup on the widget itself, only on the container that holds it.
Args:
label (str|None): The label for the widget.
widget (QWidget): The widget to be added to the toolbar.
adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True.
"""
def __init__(
self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
parent=None,
):
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
self.widget = widget
self.container = None
self.adjust_size = adjust_size
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the widget to the toolbar.
Args:
toolbar (QToolBar): The toolbar to add the widget to.
target (QWidget): The target widget for the action.
"""
self.container = QWidget(parent=target)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
if self.label is not None:
label_widget = QLabel(text=f"{self.label}", parent=target)
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox) and self.adjust_size:
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.widget.setSizePolicy(size_policy)
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
layout.addWidget(self.widget)
toolbar.addWidget(self.container)
# Store the container as the action to allow toggling visibility.
self.action = self.container
def cleanup(self):
"""
Cleans up the action by closing and deleting the container widget.
This method will be called automatically when the toolbar is cleaned up.
"""
if self.container is not None:
self.container.close()
self.container.deleteLater()
return super().cleanup()
@staticmethod
def calculate_minimum_width(combo_box: QComboBox) -> int:
font_metrics = combo_box.fontMetrics()
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
return max_width + 60
class ExpandableMenuAction(ToolBarAction):
"""
Action for an expandable menu in the toolbar.
Args:
label (str): The label for the menu.
actions (dict): A dictionary of actions to populate the menu.
icon_path (str, optional): The path to the icon file. Defaults to None.
"""
def __init__(self, label: str, actions: dict, icon_path: str = None):
super().__init__(icon_path, label)
self.actions = actions
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
button.setObjectName("toolbarMenuButton")
button.setAutoRaise(True)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)
button.setPopupMode(QToolButton.InstantPopup)
button.setStyleSheet(
"""
QToolButton {
font-size: 14px;
}
QMenu {
font-size: 14px;
}
"""
)
menu = QMenu(button)
for action_container in self.actions.values():
action: QAction = action_container.action
action.setIconVisibleInMenu(True)
if action_container.icon_path:
icon = QIcon()
icon.addFile(action_container.icon_path, size=QSize(20, 20))
action.setIcon(icon)
elif hasattr(action, "get_icon") and callable(action_container.get_icon):
sub_icon = action_container.get_icon()
if sub_icon and not sub_icon.isNull():
action.setIcon(sub_icon)
action.setCheckable(action_container.checkable)
menu.addAction(action)
button.setMenu(menu)
toolbar.addWidget(button)
class DeviceComboBoxAction(WidgetAction):
"""
Action for a device selection combobox in the toolbar.
Args:
label (str): The label for the combobox.
device_combobox (QComboBox): The combobox for selecting the device.
"""
def __init__(
self,
target_widget: QWidget,
device_filter: list[BECDeviceFilter] | None = None,
readout_priority_filter: (
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
) = None,
tooltip: str | None = None,
add_empty_item: bool = False,
no_check_delegate: bool = False,
):
self.combobox = DeviceComboBox(
parent=target_widget,
device_filter=device_filter,
readout_priority_filter=readout_priority_filter,
)
super().__init__(widget=self.combobox, adjust_size=False)
if add_empty_item:
self.combobox.addItem("", None)
self.combobox.setCurrentText("")
if tooltip is not None:
self.combobox.setToolTip(tooltip)
if no_check_delegate:
self.combobox.setItemDelegate(NoCheckDelegate(self.combobox))
def cleanup(self):
"""
Cleans up the action by closing and deleting the combobox widget.
This method will be called automatically when the toolbar is cleaned up.
"""
if self.combobox is not None:
self.combobox.close()
self.combobox.deleteLater()
return super().cleanup()

View File

@@ -1,244 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from typing import TYPE_CHECKING, DefaultDict
from weakref import ReferenceType
import louie
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class ActionInfo(BaseModel):
action: ToolBarAction
toolbar_bundle: ToolbarBundle | None = None
model_config = {"arbitrary_types_allowed": True}
class ToolbarComponents:
def __init__(self, toolbar: ModularToolBar):
"""
Initializes the toolbar components.
Args:
toolbar (ModularToolBar): The toolbar to which the components will be added.
"""
self.toolbar = toolbar
self._components: dict[str, ActionInfo] = {}
self.add("separator", SeparatorAction())
def add(self, name: str, component: ToolBarAction):
"""
Adds a component to the toolbar.
Args:
component (ToolBarAction): The component to add.
"""
if name in self._components:
raise ValueError(f"Component with name '{name}' already exists.")
self._components[name] = ActionInfo(action=component, toolbar_bundle=None)
def add_safe(self, name: str, component: ToolBarAction):
"""
Adds a component to the toolbar, ensuring it does not already exist.
Args:
name (str): The name of the component.
component (ToolBarAction): The component to add.
"""
if self.exists(name):
logger.info(f"Component with name '{name}' already exists. Skipping addition.")
return
self.add(name, component)
def exists(self, name: str) -> bool:
"""
Checks if a component exists in the toolbar.
Args:
name (str): The name of the component to check.
Returns:
bool: True if the component exists, False otherwise.
"""
return name in self._components
def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]:
"""
Retrieves a component by name.
Args:
name (str): The name of the component to retrieve.
"""
if not self.exists(name):
raise KeyError(f"Component with name '{name}' does not exist.")
return louie.saferef.safe_ref(self._components[name].action)
def get_action(self, name: str) -> ToolBarAction:
"""
Retrieves a component by name.
Args:
name (str): The name of the component to retrieve.
Returns:
ToolBarAction: The action associated with the given name.
"""
if not self.exists(name):
raise KeyError(
f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}"
)
return self._components[name].action
def set_bundle(self, name: str, bundle: ToolbarBundle):
"""
Sets the bundle for a component.
Args:
name (str): The name of the component.
bundle (ToolbarBundle): The bundle to set.
"""
if not self.exists(name):
raise KeyError(f"Component with name '{name}' does not exist.")
comp = self._components[name]
if comp.toolbar_bundle is not None:
logger.info(
f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}."
)
comp.toolbar_bundle.bundle_actions.pop(name, None)
comp.toolbar_bundle = bundle
def remove_action(self, name: str):
"""
Removes a component from the toolbar.
Args:
name (str): The name of the component to remove.
"""
if not self.exists(name):
raise KeyError(f"Action with ID '{name}' does not exist.")
action_info = self._components.pop(name)
if action_info.toolbar_bundle:
action_info.toolbar_bundle.bundle_actions.pop(name, None)
self.toolbar.refresh()
action_info.toolbar_bundle = None
if hasattr(action_info.action, "cleanup"):
# Call cleanup if the action has a cleanup method
action_info.action.cleanup()
def cleanup(self):
"""
Cleans up the toolbar components by removing all actions and bundles.
"""
for action_info in self._components.values():
if hasattr(action_info.action, "cleanup"):
# Call cleanup if the action has a cleanup method
action_info.action.cleanup()
self._components.clear()
class ToolbarBundle:
def __init__(self, name: str, components: ToolbarComponents):
"""
Initializes a new bundle component.
Args:
bundle_name (str): Unique identifier for the bundle.
"""
self.name = name
self.components = components
self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict()
self._connections: dict[str, BundleConnection] = {}
def add_action(self, name: str):
"""
Adds an action to the bundle.
Args:
name (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
if name in self.bundle_actions:
raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.")
if not self.components.exists(name):
raise ValueError(
f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method."
)
self.bundle_actions[name] = self.components.get_action_reference(name)
self.components.set_bundle(name, self)
def remove_action(self, name: str):
"""
Removes an action from the bundle.
Args:
name (str): The name of the action to remove.
"""
if name not in self.bundle_actions:
raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.")
del self.bundle_actions[name]
def add_separator(self):
"""
Adds a separator action to the bundle.
"""
self.add_action("separator")
def add_connection(self, name: str, connection):
"""
Adds a connection to the bundle.
Args:
name (str): Unique identifier for the connection.
connection: The connection to add.
"""
if name in self._connections:
raise ValueError(
f"Connection with name '{name}' already exists in bundle '{self.name}'."
)
self._connections[name] = connection
def remove_connection(self, name: str):
"""
Removes a connection from the bundle.
Args:
name (str): The name of the connection to remove.
"""
if name not in self._connections:
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
self._connections[name].disconnect()
del self._connections[name]
def get_connection(self, name: str):
"""
Retrieves a connection by name.
Args:
name (str): The name of the connection to retrieve.
Returns:
The connection associated with the given name.
"""
if name not in self._connections:
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
return self._connections[name]
def disconnect(self):
"""
Disconnects all connections in the bundle.
"""
for connection in self._connections.values():
connection.disconnect()
self._connections.clear()

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from abc import abstractmethod
from qtpy.QtCore import QObject
class BundleConnection(QObject):
bundle_name: str
@abstractmethod
def connect(self):
"""
Connects the bundle to the target widget or application.
This method should be implemented by subclasses to define how the bundle interacts with the target.
"""
@abstractmethod
def disconnect(self):
"""
Disconnects the bundle from the target widget or application.
This method should be implemented by subclasses to define how to clean up connections.
"""

View File

@@ -1,64 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection
if TYPE_CHECKING:
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
def performance_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a performance toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The performance toolbar bundle.
"""
components.add_safe(
"fps_monitor",
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar
),
)
bundle = ToolbarBundle("performance", components)
bundle.add_action("fps_monitor")
return bundle
class PerformanceConnection(BundleConnection):
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "performance"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "enable_fps_monitor"):
raise AttributeError("Target widget must implement 'enable_fps_monitor'.")
super().__init__()
self._connected = False
@SafeSlot(bool)
def set_fps_monitor(self, enabled: bool):
setattr(self.target_widget, "enable_fps_monitor", enabled)
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
self.set_fps_monitor
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
self.set_fps_monitor
)
self._connected = False

View File

@@ -1,513 +0,0 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import sys
from collections import defaultdict
from typing import DefaultDict, Literal
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme, get_theme_name
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
logger = bec_logger.logger
# Ensure that icons are shown in menus (especially on macOS)
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
class ModularToolBar(QToolBar):
"""Modular toolbar with optional automatic initialization.
Args:
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
"""
def __init__(
self,
parent=None,
orientation: Literal["horizontal", "vertical"] = "horizontal",
background_color: str = "rgba(0, 0, 0, 0)",
):
super().__init__(parent=parent)
self.background_color = background_color
self.set_background_color(self.background_color)
# Set the initial orientation
self.set_orientation(orientation)
self.components = ToolbarComponents(self)
# Initialize bundles
self.bundles: dict[str, ToolbarBundle] = {}
self.shown_bundles: list[str] = []
#########################
# outdated items... remove
self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict()
########################
def new_bundle(self, name: str) -> ToolbarBundle:
"""
Creates a new bundle component.
Args:
name (str): Unique identifier for the bundle.
Returns:
ToolbarBundle: The new bundle component.
"""
if name in self.bundles:
raise ValueError(f"Bundle with name '{name}' already exists.")
bundle = ToolbarBundle(name=name, components=self.components)
self.bundles[name] = bundle
return bundle
def add_bundle(self, bundle: ToolbarBundle):
"""
Adds a bundle component to the toolbar.
Args:
bundle (ToolbarBundle): The bundle component to add.
"""
if bundle.name in self.bundles:
raise ValueError(f"Bundle with name '{bundle.name}' already exists.")
self.bundles[bundle.name] = bundle
if not bundle.bundle_actions:
logger.warning(f"Bundle '{bundle.name}' has no actions.")
def remove_bundle(self, name: str):
"""
Removes a bundle component by name.
Args:
name (str): The name of the bundle to remove.
"""
if name not in self.bundles:
raise KeyError(f"Bundle with name '{name}' does not exist.")
del self.bundles[name]
if name in self.shown_bundles:
self.shown_bundles.remove(name)
logger.info(f"Bundle '{name}' removed from the toolbar.")
def get_bundle(self, name: str) -> ToolbarBundle:
"""
Retrieves a bundle component by name.
Args:
name (str): The name of the bundle to retrieve.
Returns:
ToolbarBundle: The bundle component.
"""
if name not in self.bundles:
raise KeyError(
f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}"
)
return self.bundles[name]
def show_bundles(self, bundle_names: list[str]):
"""
Sets the bundles to be shown for the toolbar.
Args:
bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden.
"""
self.clear()
for requested_bundle in bundle_names:
bundle = self.get_bundle(requested_bundle)
for bundle_action in bundle.bundle_actions.values():
action = bundle_action()
if action is None:
logger.warning(
f"Action for bundle '{requested_bundle}' has been deleted. Skipping."
)
continue
action.add_to_toolbar(self, self.parent())
separator = self.components.get_action_reference("separator")()
if separator is not None:
separator.add_to_toolbar(self, self.parent())
self.update_separators() # Ensure separators are updated after showing bundles
self.shown_bundles = bundle_names
def add_action(self, action_name: str, action: ToolBarAction):
"""
Adds a single action to the toolbar. It will create a new bundle
with the same name as the action.
Args:
action_name (str): Unique identifier for the action.
action (ToolBarAction): The action to add.
"""
self.components.add_safe(action_name, action)
bundle = ToolbarBundle(name=action_name, components=self.components)
bundle.add_action(action_name)
self.add_bundle(bundle)
def hide_action(self, action_name: str):
"""
Hides a specific action in the toolbar.
Args:
action_name (str): Unique identifier for the action to hide.
"""
action = self.components.get_action(action_name)
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(False)
self.update_separators()
def show_action(self, action_name: str):
"""
Shows a specific action in the toolbar.
Args:
action_name (str): Unique identifier for the action to show.
"""
action = self.components.get_action(action_name)
if hasattr(action, "action") and action.action is not None:
action.action.setVisible(True)
self.update_separators()
@property
def toolbar_actions(self) -> list[ToolBarAction]:
"""
Returns a list of all actions currently in the toolbar.
Returns:
list[ToolBarAction]: List of actions in the toolbar.
"""
actions = []
for bundle in self.shown_bundles:
if bundle not in self.bundles:
continue
for action in self.bundles[bundle].bundle_actions.values():
action_instance = action()
if action_instance is not None:
actions.append(action_instance)
return actions
def refresh(self):
"""Refreshes the toolbar by clearing and re-populating it."""
self.clear()
self.show_bundles(self.shown_bundles)
def connect_bundle(self, connection_name: str, connector: BundleConnection):
"""
Connects a bundle to a target widget or application.
Args:
bundle_name (str): The name of the bundle to connect.
connector (BundleConnection): The connector instance that implements the connection logic.
"""
bundle_name = connector.bundle_name
if bundle_name not in self.bundles:
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
connector.connect()
self.bundles[bundle_name].add_connection(connection_name, connector)
def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None):
"""
Disconnects a bundle connection.
Args:
bundle_name (str): The name of the bundle to disconnect.
connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle.
"""
if bundle_name not in self.bundles:
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
bundle = self.bundles[bundle_name]
if connection_name is None:
# Disconnect all connections in the bundle
bundle.disconnect()
else:
bundle.remove_connection(name=connection_name)
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
"""
Sets the background color and other appearance settings.
Args:
color (str): The background color of the toolbar.
"""
self.setIconSize(QSize(20, 20))
self.setMovable(False)
self.setFloatable(False)
self.setContentsMargins(0, 0, 0, 0)
self.background_color = color
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
"""Sets the orientation of the toolbar.
Args:
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
"""
if orientation == "horizontal":
self.setOrientation(Qt.Horizontal)
elif orientation == "vertical":
self.setOrientation(Qt.Vertical)
else:
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
def update_material_icon_colors(self, new_color: str | tuple | QColor):
"""
Updates the color of all MaterialIconAction icons.
Args:
new_color (str | tuple | QColor): The new color.
"""
for action in self.available_widgets.values():
if isinstance(action, MaterialIconAction):
action.color = new_color
updated_icon = action.get_icon()
action.action.setIcon(updated_icon)
def contextMenuEvent(self, event):
"""
Overrides the context menu event to show toolbar actions with checkboxes and icons.
Args:
event (QContextMenuEvent): The context menu event.
"""
menu = QMenu(self)
theme = get_theme_name()
if theme == "dark":
menu.setStyleSheet(
"""
QMenu {
background-color: rgba(50, 50, 50, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
}
QMenu::item:selected {
background-color: rgba(0, 0, 255, 0.2);
}
"""
)
else:
# Light theme styling
menu.setStyleSheet(
"""
QMenu {
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.2);
}
QMenu::item:selected {
background-color: rgba(0, 0, 255, 0.2);
}
"""
)
for ii, bundle in enumerate(self.shown_bundles):
self.handle_bundle_context_menu(menu, bundle)
if ii < len(self.shown_bundles) - 1:
menu.addSeparator()
menu.triggered.connect(self.handle_menu_triggered)
menu.exec_(event.globalPos())
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
"""
Adds bundle actions to the context menu.
Args:
menu (QMenu): The context menu.
bundle_id (str): The bundle identifier.
"""
bundle = self.bundles.get(bundle_id)
if not bundle:
return
for act_id in bundle.bundle_actions:
toolbar_action = self.components.get_action(act_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
toolbar_action, "action"
):
continue
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
continue
self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id)
def _add_qaction_to_menu(
self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str
):
display_name = qaction.text() or toolbar_action.tooltip or act_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setData(act_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_action_context_menu(self, menu: QMenu, action_id: str):
"""
Adds a single toolbar action to the context menu.
Args:
menu (QMenu): The context menu to which the action is added.
action_id (str): Unique identifier for the action.
"""
toolbar_action = self.available_widgets.get(action_id)
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
return
qaction = toolbar_action.action
if not isinstance(qaction, QAction):
return
display_name = qaction.text() or toolbar_action.tooltip or action_id
menu_action = QAction(display_name, self)
menu_action.setCheckable(True)
menu_action.setChecked(qaction.isVisible())
menu_action.setIconVisibleInMenu(True)
menu_action.setData(action_id) # Store the action_id
# Set the icon if available
if qaction.icon() and not qaction.icon().isNull():
menu_action.setIcon(qaction.icon())
menu.addAction(menu_action)
def handle_menu_triggered(self, action):
"""
Handles the triggered signal from the context menu.
Args:
action: Action triggered.
"""
action_id = action.data()
if action_id:
self.toggle_action_visibility(action_id)
def toggle_action_visibility(self, action_id: str, visible: bool | None = None):
"""
Toggles the visibility of a specific action.
Args:
action_id (str): Unique identifier.
visible (bool): Whether the action should be visible. If None, toggles the current visibility.
"""
if not self.components.exists(action_id):
return
tool_action = self.components.get_action(action_id)
if hasattr(tool_action, "action") and tool_action.action is not None:
if visible is None:
visible = not tool_action.action.isVisible()
tool_action.action.setVisible(visible)
self.update_separators()
def update_separators(self):
"""
Hide separators that are adjacent to another separator or have no non-separator actions between them.
"""
toolbar_actions = self.actions()
# First pass: set visibility based on surrounding non-separator actions.
for i, action in enumerate(toolbar_actions):
if not action.isSeparator():
continue
prev_visible = None
for j in range(i - 1, -1, -1):
if toolbar_actions[j].isVisible():
prev_visible = toolbar_actions[j]
break
next_visible = None
for j in range(i + 1, len(toolbar_actions)):
if toolbar_actions[j].isVisible():
next_visible = toolbar_actions[j]
break
if (prev_visible is None or prev_visible.isSeparator()) and (
next_visible is None or next_visible.isSeparator()
):
action.setVisible(False)
else:
action.setVisible(True)
# Second pass: ensure no two visible separators are adjacent.
prev = None
for action in toolbar_actions:
if action.isVisible() and action.isSeparator():
if prev and prev.isSeparator():
action.setVisible(False)
else:
prev = action
else:
if action.isVisible():
prev = action
if not toolbar_actions:
return
# Make sure the first visible action is not a separator
for i, action in enumerate(toolbar_actions):
if action.isVisible():
if action.isSeparator():
action.setVisible(False)
break
# Make sure the last visible action is not a separator
for i, action in enumerate(reversed(toolbar_actions)):
if action.isVisible():
if action.isSeparator():
action.setVisible(False)
break
def cleanup(self):
"""
Cleans up the toolbar by removing all actions and bundles.
"""
# First, disconnect all bundles
for bundle_name in list(self.bundles.keys()):
self.disconnect_bundle(bundle_name)
# Clear all components
self.components.cleanup()
self.bundles.clear()
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle
class MainWindow(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.test_label = QLabel(text="This is a test label.")
self.central_widget.layout = QVBoxLayout(self.central_widget)
self.central_widget.layout.addWidget(self.test_label)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(self.toolbar)
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
"base", PerformanceConnection(self.toolbar.components, self)
)
self.toolbar.show_bundles(["performance", "plot_export"])
self.toolbar.get_bundle("performance").add_action("save")
self.toolbar.refresh()
def enable_fps_monitor(self, enabled: bool):
"""
Example method to enable or disable FPS monitoring.
This method should be implemented in the target widget.
"""
if enabled:
self.test_label.setText("FPS Monitor Enabled")
else:
self.test_label.setText("FPS Monitor Disabled")
app = QApplication(sys.argv)
apply_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -1,18 +1,16 @@
from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
import os
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from qtpy.QtUiTools import QUiLoader
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None):
def __init__(self, baseinstance, custom_widgets: dict = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
@@ -20,9 +18,10 @@ if PYSIDE6:
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
widget = self.custom_widgets[class_name](self.baseinstance)
widget = self.custom_widgets[class_name](parent)
widget.setObjectName(name)
return widget
return super().createWidget(class_name, self.baseinstance, name)
return super().createWidget(class_name, parent, name)
class UILoader:
@@ -31,9 +30,9 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
self.custom_widgets = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict()
widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}
if PYSIDE6:
self.loader = self.load_ui_pyside6
@@ -52,7 +51,7 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
parent = parent or self.parent
loader = CustomUiLoader(parent, self.custom_widgets)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import shiboken6 as shb
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -264,48 +263,6 @@ class WidgetIO:
return WidgetIO._handlers[base]
return None
@staticmethod
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
"""
Return widgets matching the given class (or class-name string).
Args:
widget_class: Either a QWidget subclass or its class-name as a string.
recursive: If True (default), traverse all top-level widgets and their children;
if False, scan app.allWidgets() for a flat list.
Returns:
List of QWidget instances matching the class or class-name.
"""
app = QApplication.instance()
if app is None:
raise RuntimeError("No QApplication instance found.")
# Match by class-name string
if isinstance(widget_class, str):
name = widget_class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if top.__class__.__name__ == name:
result.append(top)
result.extend(
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
)
return result
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
# Match by actual class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if isinstance(top, widget_class):
result.append(top)
result.extend(top.findChildren(widget_class))
return result
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
################## for exporting and importing widget hierarchies ##################
@@ -318,168 +275,39 @@ class WidgetHierarchy:
grab_values: bool = False,
prefix: str = "",
exclude_internal_widgets: bool = True,
only_bec_widgets: bool = False,
show_parent: bool = True,
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of.
widget: Widget to print the hierarchy of
indent(int, optional): Level of indentation.
grab_values(bool,optional): Whether to grab the values of the widgets.
prefix(str,optional): Custom string prefix for indentation.
prefix(stc,optional): Custom string prefix for indentation.
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
"""
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
if grab_values:
value = WidgetIO.get_value(widget, ignore_errors=True)
value_str = f" [value: {value}]" if value is not None else ""
widget_info += value_str
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
is_bec = isinstance(widget, BECConnector)
if only_bec_widgets and not is_bec:
return
# 2) Determine and print the parent's info (closest BECConnector)
parent_info = ""
if show_parent and is_bec:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
if ancestor:
parent_label = ancestor.objectName() or ancestor.__class__.__name__
parent_info = f" parent={parent_label}"
else:
parent_info = " parent=None"
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
print(prefix + widget_info)
# 3) If it's a Waveform, explicitly print the curves
if isinstance(widget, Waveform):
for curve in widget.curves:
curve_prefix = prefix + " └─ "
print(
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
f"parent={widget.objectName()}"
)
# 4) Recursively handle each child if:
# - It's a QWidget
# - It is a BECConnector (or we don't care about filtering)
# - Its closest BECConnector parent is the current widget
for child in widget.findChildren(QWidget):
if only_bec_widgets and not isinstance(child, BECConnector):
children = widget.children()
for child in children:
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
continue
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
child_prefix = prefix + " └─ "
child_prefix = prefix + " "
arrow = "├─ " if child != children[-1] else "└─ "
WidgetHierarchy.print_widget_hierarchy(
child,
indent=indent + 1,
grab_values=grab_values,
prefix=child_prefix,
exclude_internal_widgets=exclude_internal_widgets,
only_bec_widgets=only_bec_widgets,
show_parent=show_parent,
child, indent + 1, grab_values, prefix=child_prefix + arrow
)
@staticmethod
def print_becconnector_hierarchy_from_app():
"""
Enumerate ALL BECConnector objects in the QApplication.
Also detect if a widget is a PlotBase, and add any data items
(PlotDataItem-like) that are also BECConnector objects.
Build a parent->children graph where each child's 'parent'
is its closest BECConnector ancestor. Print the entire hierarchy
from the root(s).
The result is a single, consolidated tree for your entire
running GUI, including PlotBase data items that are BECConnector.
"""
import sys
from collections import defaultdict
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.plot_base import PlotBase
# 1) Gather ALL QWidget-based BECConnector objects
all_qwidgets = QApplication.allWidgets()
bec_widgets = set(w for w in all_qwidgets if isinstance(w, BECConnector))
# 2) Also gather any BECConnector-based data items from PlotBase widgets
for w in all_qwidgets:
if isinstance(w, PlotBase) and hasattr(w, "plot_item"):
plot_item = w.plot_item
if hasattr(plot_item, "listDataItems"):
for data_item in plot_item.listDataItems():
if isinstance(data_item, BECConnector):
bec_widgets.add(data_item)
# 3) Build a map of (closest BECConnector parent) -> list of children
parent_map = defaultdict(list)
for w in bec_widgets:
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
parent_map[parent_bec].append(w)
# 4) Define a recursive printer to show each object's children
def print_tree(parent, prefix=""):
children = parent_map[parent]
for i, child in enumerate(children):
connector_class = child.__class__.__name__
connector_name = child.objectName() or connector_class
if parent is None:
parent_label = "None"
else:
parent_label = parent.objectName() or parent.__class__.__name__
line = f"{connector_class} ({connector_name}) parent={parent_label}"
# Determine tree-branch symbols
is_last = i == len(children) - 1
branch_str = "└─ " if is_last else "├─ "
print(prefix + branch_str + line)
# Recurse deeper
next_prefix = prefix + (" " if is_last else "")
print_tree(child, prefix=next_prefix)
# 5) Print top-level items (roots) whose BECConnector parent is None
roots = parent_map[None]
for r_i, root in enumerate(roots):
root_class = root.__class__.__name__
root_name = root.objectName() or root_class
line = f"{root_class} ({root_name}) parent=None"
is_last_root = r_i == len(roots) - 1
print(line)
# Recurse into its children
print_tree(root, prefix=" ")
@staticmethod
def _get_becwidget_ancestor(widget):
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
from bec_widgets.utils import BECConnector
# Guard against deleted/invalid Qt wrappers
if not shb.isValid(widget):
return None
# Retrieve first parent
parent = widget.parent() if hasattr(widget, "parent") else None
# Walk up, validating each step
while parent is not None:
if not shb.isValid(parent):
return None
if isinstance(parent, BECConnector):
return parent
parent = parent.parent() if hasattr(parent, "parent") else None
return None
@staticmethod
def export_config_to_dict(
widget: QWidget,
@@ -559,64 +387,6 @@ class WidgetHierarchy:
WidgetIO.set_value(child, value)
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
@staticmethod
def get_bec_connectors_from_parent(widget) -> list:
"""
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
including the widget itself if it is a BECConnector.
"""
from bec_widgets.utils import BECConnector
connectors: list[BECConnector] = []
if isinstance(widget, BECConnector):
connectors.append(widget)
for child in widget.findChildren(BECConnector):
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
connectors.append(child)
return connectors
@staticmethod
def find_ancestor(widget, ancestor_class) -> QWidget | None:
"""
Traverse up the parent chain to find the nearest ancestor matching ancestor_class.
ancestor_class may be a class or a class-name string.
Returns the matching ancestor, or None if none is found.
"""
# Guard against deleted/invalid Qt wrappers
if not shb.isValid(widget):
return None
# If searching for BECConnector specifically, reuse the dedicated helper
try:
from bec_widgets.utils import BECConnector # local import to avoid cycles
if ancestor_class is BECConnector or (
isinstance(ancestor_class, str) and ancestor_class == "BECConnector"
):
return WidgetHierarchy._get_becwidget_ancestor(widget)
except Exception:
# If import fails, fall back to generic traversal below
pass
# Generic traversal across QObject parent chain
parent = getattr(widget, "parent", None)
if callable(parent):
parent = parent()
while parent is not None:
if not shb.isValid(parent):
return None
try:
if isinstance(ancestor_class, str):
if parent.__class__.__name__ == ancestor_class:
return parent
else:
if isinstance(parent, ancestor_class):
return parent
except Exception:
pass
parent = parent.parent() if hasattr(parent, "parent") else None
return None
# Example usage
def hierarchy_example(): # pragma: no cover

View File

@@ -15,8 +15,6 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
@@ -31,58 +29,43 @@ class WidgetStateManager:
def __init__(self, widget):
self.widget = widget
def save_state(self, filename: str | None = None, settings: QSettings | None = None):
def save_state(self, filename: str = None):
"""
Save the state of the widget to an INI file.
Args:
filename(str): The filename to save the state to.
settings(QSettings): Optional QSettings object to save the state to.
"""
if not filename and not settings:
if not filename:
filename, _ = QFileDialog.getSaveFileName(
self.widget, "Save Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._save_widget_state_qsettings(self.widget, settings)
elif settings:
# If settings are provided, save the state to the provided QSettings object
self._save_widget_state_qsettings(self.widget, settings)
else:
logger.warning("No filename or settings provided for saving state.")
def load_state(self, filename: str | None = None, settings: QSettings | None = None):
def load_state(self, filename: str = None):
"""
Load the state of the widget from an INI file.
Args:
filename(str): The filename to load the state from.
settings(QSettings): Optional QSettings object to load the state from.
"""
if not filename and not settings:
if not filename:
filename, _ = QFileDialog.getOpenFileName(
self.widget, "Load Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._load_widget_state_qsettings(self.widget, settings)
elif settings:
# If settings are provided, load the state from the provided QSettings object
self._load_widget_state_qsettings(self.widget, settings)
else:
logger.warning("No filename or settings provided for saving state.")
def _save_widget_state_qsettings(
self, widget: QWidget, settings: QSettings, recursive: bool = True
):
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Save the state of the widget to QSettings.
Args:
widget(QWidget): The widget to save the state for.
settings(QSettings): The QSettings object to save the state to.
recursive(bool): Whether to recursively save the state of child widgets.
"""
if widget.property("skip_settings") is True:
return
@@ -105,32 +88,21 @@ class WidgetStateManager:
settings.endGroup()
# Recursively process children (only if they aren't skipped)
if not recursive:
return
direct_children = widget.children()
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
all_children = list(
set(direct_children) | set(bec_connector_children)
) # to avoid duplicates
for child in all_children:
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._save_widget_state_qsettings(child, settings, False)
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(
self, widget: QWidget, settings: QSettings, recursive: bool = True
):
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Load the state of the widget from QSettings.
Args:
widget(QWidget): The widget to load the state for.
settings(QSettings): The QSettings object to load the state from.
recursive(bool): Whether to recursively load the state of child widgets.
"""
if widget.property("skip_settings") is True:
return
@@ -146,21 +118,14 @@ class WidgetStateManager:
widget.setProperty(name, value)
settings.endGroup()
if not recursive:
return
# Recursively process children (only if they aren't skipped)
direct_children = widget.children()
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
all_children = list(
set(direct_children) | set(bec_connector_children)
) # to avoid duplicates
for child in all_children:
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._load_widget_state_qsettings(child, settings, False)
self._load_widget_state_qsettings(child, settings)
def _get_full_widget_name(self, widget: QWidget):
"""

View File

@@ -1,911 +0,0 @@
from __future__ import annotations
import os
from typing import Literal, cast
import PySide6QtAds as QtAds
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from shiboken6 import isValid
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
WidgetAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
is_profile_readonly,
list_profiles,
open_settings,
profile_path,
read_manifest,
set_profile_readonly,
write_manifest,
)
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
WorkspaceConnection,
workspace_bundle,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class DockSettingsDialog(QDialog):
def __init__(self, parent: QWidget, target: QWidget):
super().__init__(parent)
self.setWindowTitle("Dock Settings")
self.setModal(True)
layout = QVBoxLayout(self)
# Property editor
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
layout.addWidget(self.prop_editor)
class SaveProfileDialog(QDialog):
"""Dialog for saving workspace profiles with read-only option."""
def __init__(self, parent: QWidget, current_name: str = ""):
super().__init__(parent)
self.setWindowTitle("Save Workspace Profile")
self.setModal(True)
self.resize(400, 150)
layout = QVBoxLayout(self)
# Name input
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Profile Name:"))
self.name_edit = QLineEdit(current_name)
self.name_edit.setPlaceholderText("Enter profile name...")
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# Read-only checkbox
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
layout.addWidget(self.readonly_checkbox)
# Info label
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
info_label.setStyleSheet("color: gray; font-size: 10px;")
layout.addWidget(info_label)
# Buttons
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.save_btn = QPushButton("Save")
self.save_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
self.save_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Enable/disable save button based on name input
self.name_edit.textChanged.connect(self._update_save_button)
self._update_save_button()
def _update_save_button(self):
"""Enable save button only when name is not empty."""
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
def get_profile_name(self) -> str:
"""Get the entered profile name."""
return self.name_edit.text().strip()
def is_readonly(self) -> bool:
"""Check if the profile should be marked as read-only."""
return self.readonly_checkbox.isChecked()
class AdvancedDockArea(BECWidget, QWidget):
RPC = True
PLUGIN = False
USER_ACCESS = [
"new",
"widget_map",
"widget_list",
"lock_workspace",
"attach_all",
"delete_all",
"mode",
"mode.setter",
]
# Define a signal for mode changes
mode_changed = Signal(str)
def __init__(
self,
parent=None,
mode: str = "developer",
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
*args,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
# Title (as a top-level QWidget it can have a window title)
self.setWindowTitle("Advanced Dock Area")
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# Init Dock Manager
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
# Dock manager helper variables
self._locked = False # Lock state of the workspace
# Initialize mode property first (before toolbar setup)
self._mode = "developer"
self._default_add_direction = (
default_add_direction
if default_add_direction in ("left", "right", "top", "bottom")
else "right"
)
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self._setup_toolbar()
self._hook_toolbar()
# Place toolbar and dock manager into layout
self._root_layout.addWidget(self.toolbar)
self._root_layout.addWidget(self.dock_manager, 1)
# Populate and hook the workspace combo
self._refresh_workspace_list()
# State manager
self.state_manager = WidgetStateManager(self)
# Developer mode state
self._editable = None
# Initialize default editable state based on current lock
self._set_editable(True) # default to editable; will sync toolbar toggle below
# Sync Developer toggle icon state after initial setup
dev_action = self.toolbar.components.get_action("developer_mode").action
dev_action.setChecked(self._editable)
# Apply the requested mode after everything is set up
self.mode = mode
def _make_dock(
self,
widget: QWidget,
*,
closable: bool,
floatable: bool,
movable: bool = True,
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
start_floating: bool = False,
) -> CDockWidget:
dock = CDockWidget(widget.objectName())
dock.setWidget(widget)
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
self._install_dock_settings_action(dock, widget)
def on_dock_close():
widget.close()
dock.closeDockWidget()
dock.deleteDockWidget()
def on_widget_destroyed():
if not isValid(dock):
return
dock.closeDockWidget()
dock.deleteDockWidget()
dock.closeRequested.connect(on_dock_close)
if hasattr(widget, "widget_removed"):
widget.widget_removed.connect(on_widget_destroyed)
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
self.dock_manager.addDockWidget(area, dock)
if start_floating:
dock.setFloating()
return dock
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
action = MaterialIconAction(
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
).action
action.setToolTip("Dock settings")
action.setObjectName("dockSettingsAction")
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
dock.setTitleBarActions([action])
dock.setting_action = action
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
dlg = DockSettingsDialog(self, widget)
dlg.resize(600, 600)
dlg.exec()
def _apply_dock_lock(self, locked: bool) -> None:
if locked:
self.dock_manager.lockDockWidgetFeaturesGlobally()
else:
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
def _delete_dock(self, dock: CDockWidget) -> None:
w = dock.widget()
if w and isValid(w):
w.close()
w.deleteLater()
if isValid(dock):
dock.closeDockWidget()
dock.deleteDockWidget()
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
"""Return ADS DockWidgetArea from a human-friendly direction string.
If *where* is None, fall back to instance default.
"""
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
mapping = {
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
}
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
################################################################################
# Toolbar Setup
################################################################################
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
PLOT_ACTIONS = {
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
"scatter_waveform": (
ScatterWaveform.ICON_NAME,
"Add Scatter Waveform",
"ScatterWaveform",
),
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
"image": (Image.ICON_NAME, "Add Image", "Image"),
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
}
DEVICE_ACTIONS = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
}
UTIL_ACTIONS = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
RingProgressBar.ICON_NAME,
"Add Circular ProgressBar",
"RingProgressBar",
),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
# Create expandable menu actions (original behavior)
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
self.toolbar.components.add_safe(
key,
ExpandableMenuAction(
label=label,
actions={
k: MaterialIconAction(
icon_name=v[0], tooltip=v[1], filled=True, parent=self
)
for k, v in mapping.items()
},
),
)
b = ToolbarBundle(key, self.toolbar.components)
b.add_action(key)
self.toolbar.add_bundle(b)
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
# Create flat toolbar bundles for each widget type
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
# Create individual action for each widget type
flat_action_id = f"flat_{action_id}"
self.toolbar.components.add_safe(
flat_action_id,
MaterialIconAction(
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
),
)
bundle.add_action(flat_action_id)
self.toolbar.add_bundle(bundle)
_build_flat_bundles("plots", PLOT_ACTIONS)
_build_flat_bundles("devices", DEVICE_ACTIONS)
_build_flat_bundles("utils", UTIL_ACTIONS)
# Workspace
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
spacer = QWidget(parent=self.toolbar.components.toolbar)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
spacer_bundle.add_action("spacer")
self.toolbar.add_bundle(spacer_bundle)
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
)
# Dock actions
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
)
# Developer mode toggle (moved from menu into toolbar)
self.toolbar.components.add_safe(
"developer_mode",
MaterialIconAction(
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
),
)
bda = ToolbarBundle("dock_actions", self.toolbar.components)
bda.add_action("attach_all")
bda.add_action("screenshot")
bda.add_action("dark_mode")
bda.add_action("developer_mode")
self.toolbar.add_bundle(bda)
# Default bundle configuration (show menus by default)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = {
"menu_plots": PLOT_ACTIONS,
"menu_devices": DEVICE_ACTIONS,
"menu_utils": UTIL_ACTIONS,
}
def _hook_toolbar(self):
def _connect_menu(menu_key: str):
menu = self.toolbar.components.get_action(menu_key)
mapping = self._ACTION_MAPPINGS[menu_key]
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_menu("menu_plots")
_connect_menu("menu_devices")
_connect_menu("menu_utils")
# Connect flat toolbar actions
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
# Developer mode toggle
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
self._on_developer_mode_toggled
)
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self._editable = editable
# Sync the toolbar lock toggle with current mode
lock_action = self.toolbar.components.get_action("lock").action
lock_action.setChecked(not editable)
lock_action.setVisible(editable)
attach_all_action = self.toolbar.components.get_action("attach_all").action
attach_all_action.setVisible(editable)
# Show full creation menus only when editable; otherwise keep minimal set
if editable:
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
else:
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
# Keep Developer mode UI in sync
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
def _on_developer_mode_toggled(self, checked: bool) -> None:
"""Handle developer mode checkbox toggle."""
self._set_editable(checked)
################################################################################
# Adding widgets
################################################################################
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
closable: bool = True,
floatable: bool = True,
movable: bool = True,
start_floating: bool = False,
where: Literal["left", "right", "top", "bottom"] | None = None,
) -> BECWidget:
"""
Create a new widget (or reuse an instance) and add it as a dock.
Args:
widget: Widget instance or a string widget type (factory-created).
closable: Whether the dock is closable.
floatable: Whether the dock is floatable.
movable: Whether the dock is movable.
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
Returns:
The widget instance.
"""
target_area = self._area_from_where(where)
# 1) Instantiate or look up the widget
if isinstance(widget, str):
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
widget.name_established.connect(
lambda: self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
area=target_area,
)
)
return widget
# If a widget instance is passed, dock it immediately
self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
area=target_area,
)
return widget
def _create_dock_with_name(
self,
widget: BECWidget,
closable: bool = True,
floatable: bool = False,
movable: bool = True,
start_floating: bool = False,
area: QtAds.DockWidgetArea | None = None,
):
target_area = area or self._area_from_where(None)
self._make_dock(
widget,
closable=closable,
floatable=floatable,
movable=movable,
area=target_area,
start_floating=start_floating,
)
self.dock_manager.setFocus()
################################################################################
# Dock Management
################################################################################
def dock_map(self) -> dict[str, CDockWidget]:
"""
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
Returns:
dict: A dictionary mapping widget names to their corresponding dock widgets.
"""
return self.dock_manager.dockWidgetsMap()
def dock_list(self) -> list[CDockWidget]:
"""
Return the list of dock widgets.
Returns:
list: A list of all dock widgets in the dock area.
"""
return self.dock_manager.dockWidgets()
def widget_map(self) -> dict[str, QWidget]:
"""
Return a dictionary mapping widget names to their corresponding BECWidget instances.
Returns:
dict: A dictionary mapping widget names to BECWidget instances.
"""
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
def widget_list(self) -> list[QWidget]:
"""
Return a list of all BECWidget instances in the dock area.
Returns:
list: A list of all BECWidget instances in the dock area.
"""
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
@SafeSlot()
def attach_all(self):
"""
Return all floating docks to the dock area, preserving tab groups within each floating container.
"""
for container in self.dock_manager.floatingWidgets():
docks = container.dockWidgets()
if not docks:
continue
target = docks[0]
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
for d in docks[1:]:
self.dock_manager.addDockWidgetTab(
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
)
@SafeSlot()
def delete_all(self):
"""Delete all docks and widgets."""
for dock in list(self.dock_manager.dockWidgets()):
self._delete_dock(dock)
################################################################################
# Workspace Management
################################################################################
@SafeProperty(bool)
def lock_workspace(self) -> bool:
"""
Get or set the lock state of the workspace.
Returns:
bool: True if the workspace is locked, False otherwise.
"""
return self._locked
@lock_workspace.setter
def lock_workspace(self, value: bool):
"""
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
Args:
value (bool): True to lock the workspace, False to unlock it.
"""
self._locked = value
self._apply_dock_lock(value)
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
for dock in self.dock_list():
dock.setting_action.setVisible(not value)
@SafeSlot(str)
def save_profile(self, name: str | None = None):
"""
Save the current workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
"""
if not name:
# Use the new SaveProfileDialog instead of QInputDialog
dialog = SaveProfileDialog(self)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check if profile already exists and is read-only
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
suggested_name = f"{name}_custom"
reply = QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
f"Would you like to save it with a different name?\n"
f"Suggested name: '{suggested_name}'",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if reply == QMessageBox.Yes:
# Show dialog again with suggested name pre-filled
dialog = SaveProfileDialog(self, suggested_name)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check again if the new name is also read-only (recursive protection)
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
return self.save_profile()
else:
return
else:
# If name is provided directly, assume not read-only unless already exists
readonly = False
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
QMessageBox.Ok,
)
return
# Display saving placeholder
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
workspace_combo.blockSignals(True)
workspace_combo.insertItem(0, f"{name}-saving")
workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False)
# Save the profile
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
settings.setValue(
SETTINGS_KEYS["state"], b""
) # No QMainWindow state; placeholder for backward compat
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
self.dock_manager.addPerspective(name)
self.dock_manager.savePerspectives(settings)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
# Set read-only status if specified
if readonly:
set_profile_readonly(name, readonly)
settings.sync()
self._refresh_workspace_list()
workspace_combo.setCurrentText(name)
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
"""
# FIXME this has to be tweaked
if not name:
name, ok = QInputDialog.getText(
self, "Load Workspace", "Enter the name of the workspace profile to load:"
)
if not ok or not name:
return
settings = open_settings(name)
for item in read_manifest(settings):
obj_name = item["object_name"]
widget_class = item["widget_class"]
if obj_name not in self.widget_map():
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
w.setObjectName(obj_name)
self._make_dock(
w,
closable=item["closable"],
floatable=item["floatable"],
movable=item["movable"],
area=QtAds.DockWidgetArea.RightDockWidgetArea,
)
geom = settings.value(SETTINGS_KEYS["geom"])
if geom:
self.restoreGeometry(geom)
# No window state for QWidget-based host; keep for backwards compat read
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
if dock_state:
self.dock_manager.restoreState(dock_state)
self.dock_manager.loadPerspectives(settings)
self.state_manager.load_state(settings=settings)
self._set_editable(self._editable)
@SafeSlot()
def delete_profile(self):
"""
Delete the currently selected workspace profile file and refresh the combo list.
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
name = combo.currentText()
if not name:
return
# Check if profile is read-only
if is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
f"Read-only profiles are protected from modification and deletion.",
QMessageBox.Ok,
)
return
# Confirm deletion for regular profiles
reply = QMessageBox.question(
self,
"Delete Profile",
f"Are you sure you want to delete the profile '{name}'?\n\n"
f"This action cannot be undone.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
file_path = profile_path(name)
try:
os.remove(file_path)
except FileNotFoundError:
return
self._refresh_workspace_list()
def _refresh_workspace_list(self):
"""
Populate the workspace combo box with all saved profile names (without .ini).
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
if hasattr(combo, "refresh_profiles"):
combo.refresh_profiles()
else:
# Fallback for regular QComboBox
combo.blockSignals(True)
combo.clear()
combo.addItems(list_profiles())
combo.blockSignals(False)
################################################################################
# Mode Switching
################################################################################
@SafeProperty(str)
def mode(self) -> str:
return self._mode
@mode.setter
def mode(self, new_mode: str):
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
raise ValueError(f"Invalid mode: {new_mode}")
self._mode = new_mode
self.mode_changed.emit(new_mode)
# Update toolbar visibility based on mode
if new_mode == "user":
# User mode: show only essential tools
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
elif new_mode == "developer":
# Developer mode: show all tools (use menu bundles)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
elif new_mode in ["plot", "device", "utils"]:
# Specific modes: show flat toolbar for that category
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
self.toolbar.show_bundles([bundle_name])
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
else:
# Fallback to user mode
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
def cleanup(self):
"""
Cleanup the dock area.
"""
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.toolbar.cleanup()
super().cleanup()
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
ads = AdvancedDockArea(mode="developer", root_widget=True)
window.setCentralWidget(ads)
window.show()
window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -1,79 +0,0 @@
import os
from PySide6QtAds import CDockWidget
from qtpy.QtCore import QSettings
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
def profiles_dir() -> str:
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
os.makedirs(path, exist_ok=True)
return path
def profile_path(name: str) -> str:
return os.path.join(profiles_dir(), f"{name}.ini")
SETTINGS_KEYS = {
"geom": "mainWindow/Geometry",
"state": "mainWindow/State",
"ads_state": "mainWindow/DockingState",
"manifest": "manifest/widgets",
"readonly": "profile/readonly",
}
def list_profiles() -> list[str]:
return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini"))
def is_profile_readonly(name: str) -> bool:
"""Check if a profile is marked as read-only."""
settings = open_settings(name)
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
def set_profile_readonly(name: str, readonly: bool) -> None:
"""Set the read-only status of a profile."""
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
settings.sync()
def open_settings(name: str) -> QSettings:
return QSettings(profile_path(name), QSettings.IniFormat)
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
for i, dock in enumerate(docks):
settings.setArrayIndex(i)
w = dock.widget()
settings.setValue("object_name", w.objectName())
settings.setValue("widget_class", w.__class__.__name__)
settings.setValue("closable", getattr(dock, "_default_closable", True))
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
settings.setValue("movable", getattr(dock, "_default_movable", True))
settings.endArray()
def read_manifest(settings: QSettings) -> list[dict]:
items: list[dict] = []
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
for i in range(count):
settings.setArrayIndex(i)
items.append(
{
"object_name": settings.value("object_name"),
"widget_class": settings.value("widget_class"),
"closable": settings.value("closable", type=bool),
"floatable": settings.value("floatable", type=bool),
"movable": settings.value("movable", type=bool),
}
)
settings.endArray()
return items

View File

@@ -1,183 +0,0 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
from bec_widgets import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
is_profile_readonly,
list_profiles,
)
class ProfileComboBox(QComboBox):
"""Custom combobox that displays icons for read-only profiles."""
def __init__(self, parent=None):
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def refresh_profiles(self):
"""Refresh the profile list with appropriate icons."""
current_text = self.currentText()
self.blockSignals(True)
self.clear()
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
for profile in list_profiles():
if is_profile_readonly(profile):
self.addItem(lock_icon, f"{profile}")
# Set tooltip for read-only profiles
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
else:
self.addItem(profile)
# Restore selection if possible
index = self.findText(current_text)
if index >= 0:
self.setCurrentIndex(index)
self.blockSignals(False)
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for AdvancedDockArea.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The workspace toolbar bundle.
"""
# Lock icon action
components.add_safe(
"lock",
MaterialIconAction(
icon_name="lock_open_right",
tooltip="Lock Workspace",
checkable=True,
parent=components.toolbar,
),
)
# Workspace combo
combo = ProfileComboBox(parent=components.toolbar)
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
# Save the current workspace icon
components.add_safe(
"save_workspace",
MaterialIconAction(
icon_name="save",
tooltip="Save Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
# Delete workspace icon
components.add_safe(
"refresh_workspace",
MaterialIconAction(
icon_name="refresh",
tooltip="Refresh Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
# Delete workspace icon
components.add_safe(
"delete_workspace",
MaterialIconAction(
icon_name="delete",
tooltip="Delete Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("workspace", components)
bundle.add_action("lock")
bundle.add_action("workspace_combo")
bundle.add_action("save_workspace")
bundle.add_action("refresh_workspace")
bundle.add_action("delete_workspace")
return bundle
class WorkspaceConnection(BundleConnection):
"""
Connection class for workspace actions in AdvancedDockArea.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
super().__init__(parent=components.toolbar)
self.bundle_name = "workspace"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "lock_workspace"):
raise AttributeError("Target widget must implement 'lock_workspace'.")
self._connected = False
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
self.components.get_action("save_workspace").action.triggered.connect(
self.target_widget.save_profile
)
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
self.target_widget.load_profile
)
self.components.get_action("refresh_workspace").action.triggered.connect(
self._refresh_workspace
)
self.components.get_action("delete_workspace").action.triggered.connect(
self.target_widget.delete_profile
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
self.components.get_action("save_workspace").action.triggered.disconnect(
self.target_widget.save_profile
)
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
self.target_widget.load_profile
)
self.components.get_action("refresh_workspace").action.triggered.disconnect(
self._refresh_workspace
)
self.components.get_action("delete_workspace").action.triggered.disconnect(
self.target_widget.delete_profile
)
self._connected = False
@SafeSlot(bool)
def _lock_workspace(self, value: bool):
"""
Switches the workspace lock state and change the icon accordingly.
"""
setattr(self.target_widget, "lock_workspace", value)
self.components.get_action("lock").action.setChecked(value)
icon = material_icon(
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.components.get_action("lock").action.setIcon(icon)
@SafeSlot()
def _refresh_workspace(self):
"""
Refreshes the current workspace.
"""
combo = self.components.get_action("workspace_combo").widget
current_workspace = combo.currentText()
self.target_widget.load_profile(current_workspace)

View File

@@ -1,365 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
PLUGIN = False
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"
def __init__(
self, parent=None, gui_id: str = None, window_title="Auto Update", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None
self._default_dock = None # type:ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = True
self.start_auto_update()
def start_auto_update(self):
"""
Establish all connections for the auto updates.
"""
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
def stop_auto_update(self):
"""
Disconnect all connections for the auto updates.
"""
self.bec_dispatcher.disconnect_slot(
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
)
@property
def selected_device(self) -> str | None:
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
return self._auto_update_selected_device
@selected_device.setter
def selected_device(self, value: str | None) -> None:
"""
Set the selected device in the auto update config.
Args:
value(str): The selected device.
"""
self._auto_update_selected_device = value
@SafeSlot()
def _on_scan_status(self, content: dict, metadata: dict) -> None:
"""
Callback for scan status messages.
"""
msg = ScanStatusMessage(**content, metadata=metadata)
if not self.enabled:
return
self.enable_gui_highlights(True)
match msg.status:
case "open":
self.on_scan_open(msg)
case "closed":
self.on_scan_closed(msg)
case ["aborted", "halted"]:
self.on_scan_abort(msg)
case _:
pass
def start_default_dock(self):
"""
Create a default dock for the auto updates.
"""
self.dock_name = "update_dock"
self._default_dock = self.dock_area.new(self.dock_name)
self.current_widget = self._default_dock.new("Waveform")
@overload
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["Image"]) -> Image: ...
@overload
def set_dock_to_widget(self, widget: Literal["ScatterWaveform"]) -> ScatterWaveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["MotorMap"]) -> MotorMap: ...
@overload
def set_dock_to_widget(self, widget: Literal["MultiWaveform"]) -> MultiWaveform: ...
def set_dock_to_widget(
self,
widget: Literal["Waveform", "Image", "ScatterWaveform", "MotorMap", "MultiWaveForm"] | str,
) -> BECWidget:
"""
Set the dock to the widget.
Args:
widget (str): The widget to set the dock to. Must be the name of a valid widget class.
Returns:
BECWidget: The widget that was set.
"""
if self._default_dock is None or self.current_widget is None:
logger.warning(
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
)
self.start_default_dock()
assert self.current_widget is not None
if not self.current_widget.__class__.__name__ == widget:
self._default_dock.delete(self.current_widget.object_name)
self.current_widget = self._default_dock.new(widget)
return self.current_widget
def get_selected_device(
self, monitored_devices, selected_device: str | None = None
) -> str | None:
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device is None:
selected_device = self.selected_device
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def enable_gui_highlights(self, enable: bool) -> None:
"""
Enable or disable GUI highlights.
Args:
enable (bool): Whether to enable or disable the highlights.
"""
if enable:
title = self.dock_area.window().windowTitle()
if " [Auto Updates]" in title:
return
self.dock_area.window().setWindowTitle(f"{title} [Auto Updates]")
else:
title = self.dock_area.window().windowTitle()
self.dock_area.window().setWindowTitle(title.replace(" [Auto Updates]", ""))
@property
def enabled(self) -> bool:
"""
Get the enabled status of the auto updates.
"""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
"""
Set the enabled status of the auto updates.
"""
if self._enabled == value:
return
self._enabled = value
if value:
self.start_auto_update()
self.enable_gui_highlights(True)
self.on_start()
else:
self.stop_auto_update()
self.enable_gui_highlights(False)
self.on_stop()
def cleanup(self) -> None:
"""
Cleanup procedure to run when the auto updates are disabled.
"""
self.enabled = False
self.stop_auto_update()
self.dock_area.close()
self.dock_area.deleteLater()
self.dock_area = None
super().cleanup()
########################################################################
################# Update Functions #####################################
########################################################################
def simple_line_scan(self, info: ScanStatusMessage) -> None:
"""
Simple line scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Get the scan report devices reported by the scan
dev_x = info.scan_report_devices[0] # type:ignore
# For the y axis, get the selected device
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Clear the waveform widget and plot the data
# with the scan number and device names
# as the label and title
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
)
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
"""
Simple grid scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the scatter waveform widget
scatter = self.set_dock_to_widget("ScatterWaveform")
# Get the scan report devices reported by the scan
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if None in (dev_x, dev_y, dev_z):
return
# Clear the scatter waveform widget and plot the data
scatter.clear_all()
scatter.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
logger.info(
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
)
def best_effort(self, info: ScanStatusMessage) -> None:
"""
Best effort scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# If the scan report devices are empty, there is nothing we can do
if not info.scan_report_devices:
return
dev_x = info.scan_report_devices[0] # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Clear the waveform widget and plot the data
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
#######################################################################
################# GUI Callbacks #######################################
#######################################################################
def on_start(self) -> None:
"""
Procedure to run when the auto updates are enabled.
"""
self.start_default_dock()
def on_stop(self) -> None:
"""
Procedure to run when the auto updates are disabled.
"""
def on_scan_open(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan starts.
Args:
msg (ScanStatusMessage): The scan status message.
"""
if msg.scan_name == "line_scan" and msg.scan_report_devices:
return self.simple_line_scan(msg)
if msg.scan_name == "grid_scan" and msg.scan_report_devices:
return self.simple_grid_scan(msg)
if msg.scan_report_devices:
return self.best_effort(msg)
return None
def on_scan_closed(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan ends.
Args:
msg (ScanStatusMessage): The scan status message.
"""
def on_scan_abort(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan is aborted.
Args:
msg (ScanStatusMessage): The scan status message.
"""

View File

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

View File

@@ -12,11 +12,10 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
@@ -131,9 +130,9 @@ class BECDock(BECWidget, Dock):
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
parent_id: str | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
client=None,
gui_id: str | None = None,
closable: bool = True,
@@ -149,17 +148,12 @@ class BECDock(BECWidget, Dock):
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
label = CustomDockLabel(text=name, closable=closable)
super().__init__(
parent=parent_dock_area,
name=name,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
label=label,
**kwargs,
)
client=client, config=config, gui_id=gui_id, name=name, parent_id=parent_id
) # Name was checked and created in BEC Widget
label = CustomDockLabel(text=name, closable=closable)
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
# Dock.__init__(self, name=name, **kwargs)
self.parent_dock_area = parent_dock_area
# Layout Manager
@@ -199,7 +193,7 @@ class BECDock(BECWidget, Dock):
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget.object_name, widget) for widget in self.element_list)
return dict((widget._name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
@@ -271,15 +265,13 @@ class BECDock(BECWidget, Dock):
"""
return list(widget_handler.widget_classes.keys())
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
if (docks := self.parent_dock_area.panel_list) is None:
return []
def _get_list_of_widget_name_of_parent_dock_area(self):
docks = self.parent_dock_area.panel_list
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
@@ -302,15 +294,28 @@ class BECDock(BECWidget, Dock):
colspan(int): The number of columns the widget should span.
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
# row = cast(int, self.layout.rowCount()) # type:ignore
row = self.layout.rowCount()
# row = cast(int, row)
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
if name is not None: # Name is provided
if name in existing_widgets_parent_dock:
# pylint: disable=protected-access
raise ValueError(
f"Name {name} must be unique for widgets, but already exists in DockArea "
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
)
else: # Name is not provided
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
name = WidgetContainerUtils.generate_unique_name(
name=widget_class_name, list_of_names=existing_widgets_parent_dock
)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
@@ -320,16 +325,16 @@ class BECDock(BECWidget, Dock):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget, object_name=name, parent_dock=self, parent=self
widget_type=widget, name=name, parent_dock=self, parent_id=self.gui_id
),
)
else:
widget.object_name = name
widget._name = name # pylint: disable=protected-access
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if hasattr(widget, "config"):
widget.config.gui_id = widget.gui_id
self.config.widgets[widget.object_name] = widget.config
self.config.widgets[widget._name] = widget.config # pylint: disable=protected-access
return widget
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
@@ -359,7 +364,7 @@ class BECDock(BECWidget, Dock):
"""
Remove the dock from the parent dock area.
"""
self.parent_dock_area.delete(self.object_name)
self.parent_dock_area.delete(self._name)
def delete(self, widget_name: str) -> None:
"""
@@ -369,7 +374,7 @@ class BECDock(BECWidget, Dock):
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
widgets = [widget for widget in self.widgets if widget._name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
@@ -385,18 +390,17 @@ class BECDock(BECWidget, Dock):
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget.object_name, None)
self.config.widgets.pop(widget._name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
widget.deleteLater()
def delete_all(self):
"""
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget.object_name)
self.delete(widget._name) # pylint: disable=protected-access
def cleanup(self):
"""
@@ -417,7 +421,6 @@ class BECDock(BECWidget, Dock):
self.delete_all()
self.widgets.clear()
super().cleanup()
self.deleteLater()
def close(self):
"""

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
@@ -14,21 +15,16 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.toolbars.actions import (
from bec_widgets.utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
WidgetAction,
ModularToolBar,
SeparatorAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
@@ -51,10 +47,6 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
"""
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
"""
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
@@ -70,8 +62,8 @@ class BECDockArea(BECWidget, QWidget):
"remove",
"detach_dock",
"attach_all",
"selected_device",
"save_state",
"screenshot",
"restore_state",
]
@@ -81,7 +73,7 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
object_name: str = None,
name: str | None = None,
**kwargs,
) -> None:
if config is None:
@@ -90,263 +82,155 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(
parent=parent,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
**kwargs,
)
self._parent = parent # TODO probably not needed
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
QWidget.__init__(self, parent=parent)
self._parent = parent
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self._instructions_visible = True
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(parent=self)
self._setup_toolbar()
self.dock_area = DockArea()
self.toolbar = ModularToolBar(
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
),
},
),
"separator_0": SeparatorAction(),
"menu_devices": ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
),
},
),
"separator_1": SeparatorAction(),
"menu_utils": ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
),
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
),
},
),
"separator_2": SeparatorAction(),
"attach_all": MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks"
),
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
"restore_state": MaterialIconAction(
icon_name="frame_reload", tooltip="Restore Dock State"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.dock_area)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self._hook_toolbar()
self.toolbar.show_bundles(
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
)
def minimumSizeHint(self):
return QSize(800, 600)
def _setup_toolbar(self):
# Add plot menu
self.toolbar.components.add_safe(
"menu_plots",
ExpandableMenuAction(
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME,
tooltip="Add Waveform",
filled=True,
parent=self,
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
parent=self,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
parent=self,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME,
tooltip="Add Motor Map",
filled=True,
parent=self,
),
"heatmap": MaterialIconAction(
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
),
},
),
)
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
bundle.add_action("menu_plots")
self.toolbar.add_bundle(bundle)
# Add control menu
self.toolbar.components.add_safe(
"menu_devices",
ExpandableMenuAction(
label="Add Device Control ",
actions={
"scan_control": MaterialIconAction(
icon_name=ScanControl.ICON_NAME,
tooltip="Add Scan Control",
filled=True,
parent=self,
),
"positioner_box": MaterialIconAction(
icon_name=PositionerBox.ICON_NAME,
tooltip="Add Device Box",
filled=True,
parent=self,
),
},
),
)
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
bundle.add_action("menu_devices")
self.toolbar.add_bundle(bundle)
# Add utils menu
self.toolbar.components.add_safe(
"menu_utils",
ExpandableMenuAction(
label="Add Utils ",
actions={
"queue": MaterialIconAction(
icon_name=BECQueue.ICON_NAME,
tooltip="Add Scan Queue",
filled=True,
parent=self,
),
"vs_code": MaterialIconAction(
icon_name=VSCodeEditor.ICON_NAME,
tooltip="Add VS Code",
filled=True,
parent=self,
),
"status": MaterialIconAction(
icon_name=BECStatusBox.ICON_NAME,
tooltip="Add BEC Status Box",
filled=True,
parent=self,
),
"progress_bar": MaterialIconAction(
icon_name=RingProgressBar.ICON_NAME,
tooltip="Add Circular ProgressBar",
filled=True,
parent=self,
),
# FIXME temporarily disabled -> issue #644
"log_panel": MaterialIconAction(
icon_name=LogPanel.ICON_NAME,
tooltip="Add LogPanel - Disabled",
filled=True,
parent=self,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
),
},
),
)
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
bundle.add_action("menu_utils")
self.toolbar.add_bundle(bundle)
########## Dock Actions ##########
spacer = QWidget(parent=self)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
)
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
bundle.add_action("spacer")
bundle.add_action("dark_mode")
self.toolbar.add_bundle(bundle)
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.add_safe(
"save_state",
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
)
self.toolbar.components.add_safe(
"restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
bundle.add_action("screenshot")
self.toolbar.add_bundle(bundle)
def _hook_toolbar(self):
menu_plots = self.toolbar.components.get_action("menu_plots")
menu_devices = self.toolbar.components.get_action("menu_devices")
menu_utils = self.toolbar.components.get_action("menu_utils")
menu_plots.actions["waveform"].action.triggered.connect(
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
menu_plots.actions["scatter_waveform"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
)
menu_plots.actions["multi_waveform"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
)
menu_plots.actions["image"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Image")
)
menu_plots.actions["motor_map"].action.triggered.connect(
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
menu_plots.actions["heatmap"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
)
# Menu Devices
menu_devices.actions["scan_control"].action.triggered.connect(
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
menu_devices.actions["positioner_box"].action.triggered.connect(
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
menu_utils.actions["queue"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
menu_utils.actions["status"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
menu_utils.actions["vs_code"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
menu_utils.actions["progress_bar"].action.triggered.connect(
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
# FIXME temporarily disabled -> issue #644
menu_utils.actions["log_panel"].action.setEnabled(False)
menu_utils.actions["sbb_monitor"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
# Icons
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state
)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
name = pascal_to_snake(widget_name)
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
@@ -359,6 +243,17 @@ class BECDockArea(BECWidget, QWidget):
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def selected_device(self) -> str:
gui_id = QApplication.instance().gui_id
auto_update_config = self.client.connector.get(
MessageEndpoints.gui_auto_update_config(gui_id)
)
try:
return auto_update_config.selected_device
except AttributeError:
return None
@property
def panels(self) -> dict[str, BECDock]:
"""
@@ -410,8 +305,6 @@ class BECDockArea(BECWidget, QWidget):
"""
if state is None:
state = self.config.docks_state
if state is None:
return
self.dock_area.restoreState(state, missing=missing, extra=extra)
@SafeSlot()
@@ -459,27 +352,17 @@ class BECDockArea(BECWidget, QWidget):
Returns:
BECDock: The created dock.
"""
dock_names = [
dock.object_name for dock in self.panel_list
] # pylint: disable=protected-access
dock_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self.object_name} and id {self.gui_id}."
f"with name: {self._name} and id {self.gui_id}."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(
parent=self,
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
closable=closable,
)
dock = BECDock(name=name, parent_dock_area=self, parent_id=self.gui_id, closable=closable)
dock.config.position = position
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
@@ -547,8 +430,10 @@ class BECDockArea(BECWidget, QWidget):
Cleanup the dock area.
"""
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.toolbar.close()
self.toolbar.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
super().cleanup()
def show(self):
@@ -597,18 +482,7 @@ class BECDockArea(BECWidget, QWidget):
# self._broadcast_update()
def remove(self) -> None:
"""
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
parent = self.parent()
if isinstance(parent, BECMainWindow):
central_widget = parent.centralWidget()
if central_widget is self:
# Closing the parent will also close the dock area
parent.close()
return
"""Remove the dock area."""
self.close()
@@ -616,18 +490,16 @@ if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.colors import set_theme
app = QApplication([])
apply_theme("dark")
set_theme("auto")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="DarkModeButton")
dock_area.new(widget="Waveform")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
WidgetHierarchy.print_becconnector_hierarchy_from_app()
app.exec_()
sys.exit(app.exec_())

View File

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

View File

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

View File

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

View File

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

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