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

Compare commits

..

2 Commits

Author SHA1 Message Date
0013f3a506 ci: fix permissions for release job 2025-05-19 13:40:50 +02:00
e33de12747 ci: fix missing build dependencies 2025-05-19 13:27:48 +02:00
184 changed files with 6497 additions and 18050 deletions

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,26 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
## 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,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,9 +1,8 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEAT]: '
type: feature
label: feature
title: ''
labels: ''
assignees: ''
---

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

@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/github-script@v7
id: script
if: github.event_name == 'push' && github.event.ref_type != 'tag'
if: github.event_name == 'push'
with:
script: |
const prs = await github.rest.pulls.list({

View File

@@ -1,21 +1,5 @@
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
on: [push, pull_request]
permissions:
pull-requests: write
@@ -33,10 +17,6 @@ jobs:
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 }}
@@ -44,10 +24,6 @@ jobs:
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]

View File

@@ -12,7 +12,6 @@ jobs:
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"
@@ -40,19 +39,10 @@ jobs:
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
pip install -e ./ophyd_devices
pip install -e .[dev,pyside6]
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

View File

@@ -14,15 +14,10 @@ jobs:
- name: Run black and isort
run: |
pip install uv
uv pip install --system black isort
uv pip install --system -e .[dev]
pip install black isort
pip install -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

View File

@@ -1,26 +1,5 @@
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
on: [workflow_call]
jobs:
pytest-matrix:
@@ -30,7 +9,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
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 }}
@@ -38,22 +17,32 @@ jobs:
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Checkout BEC Widgets
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
python-version: ${{ matrix.python-version }}
- 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: 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: 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 Pytest
run: |
pip install pytest pytest-random-order
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests

View File

@@ -6,21 +6,6 @@ on:
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
@@ -35,23 +20,38 @@ jobs:
runs-on: ubuntu-latest
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:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
- uses: actions/checkout@v4
- name: Install BEC Widgets and dependencies
uses: ./.github/actions/bw_install
- name: Set up Python
uses: actions/setup-python@v5
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
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: 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 Pytest with Coverage
id: coverage

View File

@@ -41,13 +41,6 @@ jobs:
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: |
@@ -91,13 +84,56 @@ jobs:
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
- name: Semantic Version Release
- name: Action | 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
# Adjust tag with desired version if applicable.
uses: python-semantic-release/python-semantic-release@v9.21.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
git_committer_name: "github-actions"
git_committer_email: "actions@users.noreply.github.com"
- name: Publish | Upload to GitHub Release Assets
uses: python-semantic-release/publish-action@v9.21.1
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.release.outputs.tag }}
- name: Upload | Distribution Artifacts
uses: actions/upload-artifact@v4
with:
name: distribution-artifacts
path: dist
if-no-files-found: error
pypi-publish:
# 1. Separate out the deploy step from the publish step to run each step at
# the least amount of token privilege
# 2. Also, deployments can fail, and its better to have a separate job if you need to retry
# and it won't require reversing the release.
runs-on: ubuntu-latest
needs: release
if: ${{ needs.release.outputs.released == 'true' }}
environment:
name: pypi
url: https://pypi.org/p/bec-widgets
permissions:
contents: read
id-token: write
steps:
- name: Setup | Download Build Artifacts
uses: actions/download-artifact@v4
id: artifact-download
with:
name: distribution-artifacts
path: dist
# see https://docs.pypi.org/trusted-publishers/
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@v1.12.4
with:
packages-dir: dist
print-hash: true
verbose: true

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

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

@@ -0,0 +1,40 @@
## Feature Summary
[Provide a brief and clear summary of the new feature you are requesting]
## Problem Description
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
## Use Case
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
## Proposed Solution
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
## Benefits
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
## Alternatives Considered
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
## Impact on Existing Functionality
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
## Priority
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
## Attachments
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
## Additional Information
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]

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

@@ -6,7 +6,7 @@ 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.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
@@ -27,7 +27,7 @@ from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
@@ -44,7 +44,6 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal()
def __init__(
@@ -55,15 +54,9 @@ class LaunchTile(RoundedFrame):
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
tile_size: tuple[int, int] | None = None,
):
super().__init__(parent=parent, orientation="vertical")
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
tile_size = self.DEFAULT_SIZE
self.tile_size = tile_size
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
@@ -94,26 +87,12 @@ class LaunchTile(RoundedFrame):
# Main label
self.main_label = QLabel(main_label)
# Desired default appearance
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setWordWrap(True)
self.main_label.setAlignment(Qt.AlignCenter)
# Shrink font if the default would wrap on this platform / DPI
content_width = (
self.tile_size[0]
- self.layout.contentsMargins().left()
- self.layout.contentsMargins().right()
)
self._fit_label_to_width(self.main_label, content_width)
# Give every tile the same reserved height for the title so the
# description labels start at an identical yoffset.
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
@@ -154,29 +133,6 @@ class LaunchTile(RoundedFrame):
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
Fit the label text to the specified maximum width by adjusting the font size.
Args:
label(QLabel): The label to adjust.
max_width(int): The maximum width the label can occupy.
min_pt(int): The minimum font point size to use.
"""
font = label.font()
for pt in range(font.pointSize(), min_pt - 1, -1):
font.setPointSize(pt)
metrics = QFontMetrics(font)
if metrics.horizontalAdvance(label.text()) <= max_width:
label.setFont(font)
label.setWordWrap(False)
return
# If nothing fits, fall back to eliding
metrics = QFontMetrics(font)
label.setFont(font)
label.setWordWrap(False)
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
class LaunchWindow(BECMainWindow):
RPC = True
@@ -190,8 +146,6 @@ class LaunchWindow(BECMainWindow):
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)
@@ -242,7 +196,7 @@ class LaunchWindow(BECMainWindow):
)
# plugin widgets
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
self.available_widgets: dict[str, BECWidget] = get_all_plugin_widgets()
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()
@@ -296,34 +250,14 @@ class LaunchWindow(BECMainWindow):
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)
tile.setFixedSize(*self.TILE_SIZE)
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(
@@ -542,7 +476,7 @@ class LaunchWindow(BECMainWindow):
remaining_connections = [
connection for connection in connections.values() if connection.parent_id != self.gui_id
]
return len(remaining_connections) <= 4
return len(remaining_connections) <= 1
def _turn_off_the_lights(self, connections: dict):
"""

File diff suppressed because it is too large Load Diff

View File

@@ -111,7 +111,7 @@ _Widgets = {
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)

View File

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

View File

@@ -6,6 +6,7 @@ import os
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
@@ -37,10 +38,6 @@ class SimpleFileLikeFromLogOutputFunc:
self._log_func(lines)
self._buffer = [remaining]
@property
def encoding(self):
return "utf-8"
def close(self):
return

View File

@@ -43,7 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"pg": pg,
"wh": wh,
"dock": self.dock,
"im": self.im,
# "im": self.im,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
@@ -112,13 +112,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# 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.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1)
# sixth_tab = QWidget()
# sixth_tab_layout = QVBoxLayout(sixth_tab)
# self.im = Image()
# self.mi = self.im.main_image
# sixth_tab_layout.addWidget(self.im)
# tab_widget.addTab(sixth_tab, "Image Next Gen")
# tab_widget.setCurrentIndex(5)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)

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": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # 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

@@ -186,6 +186,7 @@ class BECConnector:
except:
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
@SafeSlot()
def _run_cleanup_on_deleted_parent(self) -> None:
"""
Run cleanup on the deleted parent.

View File

@@ -163,7 +163,7 @@ class BECDispatcher:
def connect_slot(
self,
slot: Callable,
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
cb_info: dict | None = None,
**kwargs,
) -> None:
@@ -172,7 +172,7 @@ 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
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
@@ -183,15 +183,13 @@ class BECDispatcher:
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
):
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,

View File

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

View File

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

@@ -15,15 +15,12 @@ if TYPE_CHECKING: # pragma: no cover
from bec_qthemes._main import AccentColors
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
else:
return QApplication.instance().theme.theme
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
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:

View File

@@ -34,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)
@@ -56,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)
@@ -95,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()
@@ -256,23 +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
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
)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d)
def snap_to_data(
self, x: float, y: float
@@ -312,10 +241,8 @@ 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
# 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))
@@ -384,7 +311,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))
@@ -395,19 +321,16 @@ 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.coordinatesChanged1D.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
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
self.marker_2d.setPos([x, y])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
@@ -441,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))
@@ -453,19 +375,16 @@ 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
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
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:
@@ -505,17 +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
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)
@@ -534,12 +450,9 @@ class Crosshair(QObject):
self.clear_markers()
def cleanup(self):
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
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)

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._create_title_layout(title, icon)
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._contents = QWidget(self)
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,107 +1,76 @@
from __future__ import annotations
from decimal import Decimal
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 qtpy.QtWidgets import QGridLayout, QLabel, QLayout, 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,
)
from bec_widgets.utils.forms_from_types.items import 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"]
RPC = False
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.
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.
"""
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 = []
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
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._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
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()
@@ -109,22 +78,19 @@ class TypedForm(BECWidget, QWidget):
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 = widget_from_type(item.item_type)(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"""
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
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()
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
@@ -137,13 +103,10 @@ class TypedForm(BECWidget, QWidget):
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()
@@ -151,56 +114,23 @@ class TypedForm(BECWidget, QWidget):
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
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):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(
self,
parent=None,
data_model: type[BaseModel] | None = None,
enabled: bool = True,
pretty_display: bool = False,
client=None,
**kwargs,
):
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, 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.
metadata_model (type[BaseModel]): the model class for which to generate a form.
"""
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._md_schema = metadata_model
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
@@ -213,40 +143,13 @@ class PydanticModelForm(TypedForm):
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
)
FormItemSpec(name=name, info=info, item_type=info.annotation)
for name, info in self._md_schema.model_fields.items()
]

View File

@@ -1,44 +1,31 @@
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 types import UnionType
from typing import Callable, Protocol
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
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.QtCore import Signal # type: ignore
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,
@@ -47,7 +34,6 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_minlen,
field_precision,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
logger = bec_logger.logger
@@ -60,36 +46,9 @@ class FormItemSpec(BaseModel):
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType | GenericAlias
item_type: type | UnionType
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):
@@ -135,20 +94,10 @@ class ClearableBoolEntry(QWidget):
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()
@@ -158,17 +107,11 @@ class DynamicFormItem(QWidget):
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()
if clearable_required(spec.info):
self._add_clear_button()
@abstractmethod
def getValue(self) -> DynamicFormItemType: ...
def getValue(self): ...
@abstractmethod
def setValue(self, value): ...
@@ -178,11 +121,6 @@ class DynamicFormItem(QWidget):
"""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 "")
@@ -200,7 +138,7 @@ class DynamicFormItem(QWidget):
self.valueChanged.emit()
class StrFormItem(DynamicFormItem):
class StrMetadataField(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)
@@ -225,11 +163,11 @@ class StrFormItem(DynamicFormItem):
def setValue(self, value: str):
if value is None:
return self._main_widget.setText("")
self._main_widget.setText(str(value))
self._main_widget.setText("")
self._main_widget.setText(value)
class IntFormItem(DynamicFormItem):
class IntMetadataField(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)
@@ -258,18 +196,18 @@ class IntFormItem(DynamicFormItem):
self._main_widget.setValue(value)
class FloatDecimalFormItem(DynamicFormItem):
class FloatDecimalMetadataField(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)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -286,13 +224,13 @@ class FloatDecimalFormItem(DynamicFormItem):
return self._default
return self._main_widget.value()
def setValue(self, value: float | Decimal):
def setValue(self, value: float):
if value is None:
self._main_widget.clear()
self._main_widget.setValue(float(value))
self._main_widget.setValue(value)
class BoolFormItem(DynamicFormItem):
class BoolMetadataField(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)
@@ -313,300 +251,36 @@ class BoolFormItem(DynamicFormItem):
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._buttons = QVBoxLayout()
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
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
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:
return IntMetadataField
if annotation in [float, float | None, Decimal, Decimal | None]:
return FloatDecimalMetadataField
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
return StrMetadataField
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"])
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
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

@@ -8,9 +8,6 @@ from qtpy.QtCore import QObject
from bec_widgets.utils.name_utils import pascal_to_snake
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
_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):
@@ -93,20 +90,34 @@ 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
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)
)
# 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 init_source.find("super().__init__(parent,") > 0
or init_source.find("super().__init__(parent)") > 0
)
if not cls_init_found and not super_init_found:
raise ValueError(

View File

@@ -4,7 +4,7 @@ import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from typing import TYPE_CHECKING
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -37,6 +37,7 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
"""
modules = _get_available_plugins("bec.widgets.user_widgets")
loaded_plugins = {}
print(modules)
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_plugins)
for name, mod_cls in mods:
@@ -89,15 +90,15 @@ class BECClassInfo:
name: str
module: str
file: str
obj: type[BECWidget]
obj: type
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
class BECClassContainer:
def __init__(self, initial: Iterable[BECClassInfo] = []):
self._collection: list[BECClassInfo] = list(initial)
def __init__(self):
self._collection: list[BECClassInfo] = []
def __repr__(self):
return str(list(cl.name for cl in self.collection))
@@ -105,16 +106,6 @@ class BECClassContainer:
def __iter__(self):
return self._collection.__iter__()
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
@@ -124,44 +115,53 @@ class BECClassContainer:
"""
self.collection.append(class_info)
@property
def names(self):
"""Return a list of class names"""
return [c.name for c in self]
@property
def collection(self):
"""Get the collection of classes."""
"""
Get the collection of classes.
"""
return self._collection
@property
def connector_classes(self):
"""Get all connector classes."""
"""
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""Get all top-level classes."""
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""Get all plugins. These are all classes that are on the top level and are widgets."""
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property
def widgets(self):
"""Get all widgets. These are all classes inheriting from BECWidget."""
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""Get all classes."""
"""
Get all classes.
"""
return [info.obj for info in self.collection]
@@ -197,7 +197,7 @@ 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, BECWidget):

View File

@@ -195,7 +195,7 @@ class RPCServer:
return
self._broadcasted_data = data
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},

View File

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

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):
@@ -62,7 +61,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(parent=self, target_widget=self, orientation="vertical")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -93,7 +92,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(parent=self, target_widget=self, orientation="horizontal")
self.container = QWidget()
self.container.layout = QVBoxLayout(self.container)
@@ -289,16 +288,8 @@ class SidePanel(QWidget):
# 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)
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:

1081
bec_widgets/utils/toolbar.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,524 +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)
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,58 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
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
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
)
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(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
)

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 get_theme_name, set_theme
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)
set_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -2,14 +2,13 @@ from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
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):
@@ -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

View File

@@ -15,13 +15,12 @@ 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
@@ -105,227 +104,140 @@ class BECDockArea(BECWidget, QWidget):
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dock_area = DockArea(parent=self)
self.toolbar = ModularToolBar(parent=self)
self._setup_toolbar()
self.toolbar = ModularToolBar(
parent=self,
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(parent=self)
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
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,
),
},
),
)
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),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
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 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.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:

View File

@@ -1,115 +0,0 @@
import sys
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
class WidgetTooltip(QWidget):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self.setMouseTracking(True)
self.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
self.adjustSize()
def leaveEvent(self, _event) -> None:
self.hide()
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
self.adjustSize()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
geom = self.geometry()
x = global_pos.x() - geom.width() // 2
y = global_pos.y() - geom.height() - offset
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
class HoverWidget(QWidget):
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
super().__init__(parent)
self._simple = simple
self._full = full
self._full.setVisible(False)
self._tooltip = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(simple)
def enterEvent(self, event):
# suppress empty-label tooltips for labels
if isinstance(self._full, QLabel) and not self._full.text():
return
if self._tooltip is None: # first time only
self._tooltip = WidgetTooltip(self._full)
self._full.setVisible(True)
centre = self.mapToGlobal(self.rect().center())
self._tooltip.show_above(centre)
super().enterEvent(event)
def leaveEvent(self, event):
if self._tooltip and self._tooltip.isVisible():
self._tooltip.hide()
super().leaveEvent(event)
def close(self):
if self._tooltip:
self._tooltip.close()
self._tooltip.deleteLater()
self._tooltip = None
super().close()
################################################################################
# Demo
# Just a simple example to show how the HoverWidget can be used to display
# a tooltip with a full widget inside (two different widgets are used
# for the simple and full versions).
################################################################################
class DemoSimpleWidget(QLabel): # pragma: no cover
"""A simple widget to be used as a trigger for the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setText("Hover me for a preview!")
class DemoFullWidget(QProgressBar): # pragma: no cover
"""A full widget to be shown in the tooltip."""
def __init__(self) -> None:
super().__init__()
self.setRange(0, 100)
self.setValue(75)
self.setFixedWidth(320)
self.setFixedHeight(30)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = QWidget()
window.layout = QHBoxLayout(window)
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
window.layout.addWidget(hover_widget)
window.show()
sys.exit(app.exec_())

View File

@@ -1,110 +0,0 @@
from qtpy.QtCore import QTimer
from qtpy.QtGui import QFontMetrics, QPainter
from qtpy.QtWidgets import QLabel
class ScrollLabel(QLabel):
"""A QLabel that scrolls its text horizontally across the widget."""
def __init__(self, parent=None, speed_ms=30, step_px=1, delay_ms=2000):
super().__init__(parent=parent)
self._offset = 0
self._text_width = 0
# scrolling timer (runs continuously once started)
self._timer = QTimer(self)
self._timer.setInterval(speed_ms)
self._timer.timeout.connect(self._scroll)
# delaybeforescroll timer (singleshot)
self._delay_timer = QTimer(self)
self._delay_timer.setSingleShot(True)
self._delay_timer.setInterval(delay_ms)
self._delay_timer.timeout.connect(self._timer.start)
self._step_px = step_px
def setText(self, text):
"""
Overridden to ensure that new text replaces the current one
immediately.
If the label was already scrolling (or in its delay phase),
the next message starts **without** the extra delay.
"""
# Determine whether the widget was already in a scrolling cycle
was_scrolling = self._timer.isActive() or self._delay_timer.isActive()
super().setText(text)
fm = QFontMetrics(self.font())
self._text_width = fm.horizontalAdvance(text)
self._offset = 0
# Skip the delay when we were already scrolling
self._update_timer(skip_delay=was_scrolling)
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_timer()
def _update_timer(self, *, skip_delay: bool = False):
"""
Decide whether to start or stop scrolling.
If the text is wider than the visible area, start a singleshot
delay timer (2s by default). Scrolling begins only after this
delay. Any change (resize or new text) restarts the logic.
"""
needs_scroll = self._text_width > self.width()
if needs_scroll:
# Reset any running timers
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()
self._offset = 0
# Start scrolling immediately when we should skip the delay,
# otherwise apply the configured delay_ms interval
if skip_delay:
self._timer.start()
else:
self._delay_timer.start()
else:
if self._delay_timer.isActive():
self._delay_timer.stop()
if self._timer.isActive():
self._timer.stop()
self.update()
def _scroll(self):
self._offset += self._step_px
if self._offset >= self._text_width:
self._offset = 0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
text = self.text()
if not text:
return
fm = QFontMetrics(self.font())
y = (self.height() + fm.ascent() - fm.descent()) // 2
if self._text_width <= self.width():
painter.drawText(0, y, text)
else:
x = -self._offset
gap = 50 # space between repeating text blocks
while x < self.width():
painter.drawText(x, y, text)
x += self._text_width + gap
def cleanup(self):
"""Stop all timers to prevent memory leaks."""
if self._timer.isActive():
self._timer.stop()
if self._delay_timer.isActive():
self._delay_timer.stop()

View File

@@ -1,31 +1,17 @@
from __future__ import annotations
import os
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QStyle,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -33,8 +19,6 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
PLUGIN = False
SCAN_PROGRESS_WIDTH = 100 # px
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
def __init__(
self,
@@ -48,19 +32,10 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
self.app = QApplication.instance()
self.status_bar = self.statusBar()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
# Connections to BEC Notifications
self.bec_dispatcher.connect_slot(
self.display_client_message, MessageEndpoints.client_info()
)
################################################################################
# MainWindow Elements Initialization
################################################################################
def _init_ui(self):
# Set the icon
@@ -68,189 +43,40 @@ class BECMainWindow(BECWidget, QMainWindow):
# Set Menu and Status bar
self._setup_menu_bar()
self._init_status_bar_widgets()
# BEC Specific UI
self.display_app_id()
def _init_status_bar_widgets(self):
"""
Prepare the BEC specific widgets in the status bar.
"""
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Add a separator after the app ID label
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
self._add_client_info_label()
# Add scan_progress bar with display logic
self._add_scan_progress_bar()
################################################################################
# Client message status bar widget helpers
def _add_client_info_label(self):
"""
Add a client info label to the status bar.
This label will display messages from the BEC dispatcher.
"""
# Scroll label for client info in Status Bar
self._client_info_label = ScrollLabel(self)
self._client_info_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Full label used in the hover widget
self._client_info_label_full = QLabel(self)
self._client_info_label_full.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
# Hover widget to show the full client info label
self._client_info_hover = HoverWidget(
self, simple=self._client_info_label, full=self._client_info_label_full
)
self.status_bar.addWidget(self._client_info_hover, 1)
# Timer to automatically clear client messages once they expire
self._client_info_expire_timer = QTimer(self)
self._client_info_expire_timer.setSingleShot(True)
self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText(""))
self._client_info_expire_timer.timeout.connect(
lambda: self._client_info_label_full.setText("")
)
################################################################################
# Progressbar helpers
def _add_scan_progress_bar(self):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar_full = ScanProgressBar(self)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
)
# Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True)
self._scan_progress_bar_with_separator = QWidget()
self._scan_progress_bar_with_separator.layout = QHBoxLayout(
self._scan_progress_bar_with_separator
)
self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(0)
self._scan_progress_bar_with_separator.layout.addWidget(separator)
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
# Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
# Visibility logic
self._scan_progress_bar_with_separator.hide()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
# Timer for hiding logic
self._scan_progress_hide_timer = QTimer(self)
self._scan_progress_hide_timer.setSingleShot(True)
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
if self._scan_progress_bar_with_separator.isVisible():
return
# Make visible and reset width
self._scan_progress_bar_with_separator.show()
self._scan_progress_bar_with_separator.setMaximumWidth(0)
self._show_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._show_container_anim.setDuration(300)
self._show_container_anim.setStartValue(0)
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
self._show_container_anim.start()
def _delay_hide_scan_progress_bar(self):
"""Start the countdown to hide the scan progress bar."""
if hasattr(self, "_scan_progress_hide_timer"):
self._scan_progress_hide_timer.start()
def _animate_hide_scan_progress_bar(self):
"""Shrink container to the right, then hide."""
self._hide_container_anim = QPropertyAnimation(
self._scan_progress_bar_with_separator, b"maximumWidth", self
)
self._hide_container_anim.setDuration(300)
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
self._hide_container_anim.setEndValue(0)
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
self._hide_container_anim.start()
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
"""
Add a vertically centred separator to the status bar or just return it as a separate object.
"""
status_bar = self.statusBar()
# The actual line
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
wrapper = QWidget()
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
if separate_object:
return wrapper
status_bar.addWidget(wrapper)
def _init_bec_icon(self):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
@@ -338,64 +164,14 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
################################################################################
# Status Bar Addons
################################################################################
def display_app_id(self):
"""
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self._app_id_label.setText(status_message)
@SafeSlot(dict, dict)
def display_client_message(self, msg: dict, meta: dict):
"""
Display a client message in the status bar.
Args:
msg(dict): The message to display, should contain:
meta(dict): Metadata about the message, usually empty.
"""
message = msg.get("message", "")
expiration = msg.get("expire", 0) # 0 → never expire
self._client_info_label.setText(message)
self._client_info_label_full.setText(message)
# Restart the expiration timer if necessary
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if expiration and expiration > 0:
self._client_info_expire_timer.start(int(expiration * 1000))
################################################################################
# General and Cleanup Methods
################################################################################
@SafeSlot(str)
def change_theme(self, theme: str):
"""
Change the theme of the application.
Args:
theme(str): The theme to apply, either "light" or "dark".
"""
apply_theme(theme)
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def cleanup(self):
central_widget = self.centralWidget()
if central_widget is not None:
central_widget.close()
central_widget.deleteLater()
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
@@ -406,39 +182,8 @@ class BECMainWindow(BECWidget, QMainWindow):
child.cleanup()
child.close()
child.deleteLater()
# Timer cleanup
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
self._client_info_expire_timer.stop()
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup
# Client info label cleanup
self._client_info_label.cleanup()
self._client_info_hover.close()
self._client_info_hover.deleteLater()
# Scan progress bar cleanup
self._scan_progress_bar_simple.close()
self._scan_progress_bar_simple.deleteLater()
self._scan_progress_bar_full.close()
self._scan_progress_bar_full.deleteLater()
self._scan_progress_hover.close()
self._scan_progress_hover.deleteLater()
super().cleanup()
class UILaunchWindow(BECMainWindow):
RPC = True
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = UILaunchWindow()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -6,10 +6,10 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.widget_io import WidgetIO
@@ -100,7 +100,7 @@ class DeviceInputBase(BECWidget):
### QtSlots ###
@SafeSlot(str)
@Slot(str)
def set_device(self, device: str):
"""
Set the device.
@@ -114,7 +114,7 @@ class DeviceInputBase(BECWidget):
else:
logger.warning(f"Device {device} is not in the filtered selection.")
@SafeSlot()
@Slot()
def update_devices_from_filters(self):
"""Update the devices based on the current filter selection
in self.device_filter and self.readout_filter. If apply_filter is False,
@@ -133,7 +133,7 @@ class DeviceInputBase(BECWidget):
self.devices = [device.name for device in devs]
self.set_device(current_device)
@SafeSlot(list)
@Slot(list)
def set_available_devices(self, devices: list[str]):
"""
Set the devices. If a device in the list is not valid, it will not be considered.
@@ -146,7 +146,7 @@ class DeviceInputBase(BECWidget):
### QtProperties ###
@SafeProperty(
@Property(
"QStringList",
doc="List of devices. If updated, it will disable the apply filters property.",
)
@@ -165,7 +165,7 @@ class DeviceInputBase(BECWidget):
self.config.devices = value
FilterIO.set_selection(widget=self, selection=value)
@SafeProperty(str)
@Property(str)
def default(self):
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
return self.config.default
@@ -177,7 +177,7 @@ class DeviceInputBase(BECWidget):
self.config.default = value
WidgetIO.set_value(widget=self, value=value)
@SafeProperty(bool)
@Property(bool)
def apply_filter(self):
"""Apply the filters on the devices."""
return self.config.apply_filter
@@ -187,7 +187,7 @@ class DeviceInputBase(BECWidget):
self.config.apply_filter = value
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_device(self):
"""Include devices in filters."""
return BECDeviceFilter.DEVICE in self.device_filter
@@ -200,7 +200,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.DEVICE)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_positioner(self):
"""Include devices of type Positioner in filters."""
return BECDeviceFilter.POSITIONER in self.device_filter
@@ -213,7 +213,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.POSITIONER)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_signal(self):
"""Include devices of type Signal in filters."""
return BECDeviceFilter.SIGNAL in self.device_filter
@@ -226,7 +226,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.SIGNAL)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def filter_to_computed_signal(self):
"""Include devices of type ComputedSignal in filters."""
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
@@ -239,7 +239,7 @@ class DeviceInputBase(BECWidget):
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_monitored(self):
"""Include devices with readout priority Monitored in filters."""
return ReadoutPriority.MONITORED in self.readout_filter
@@ -252,7 +252,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.MONITORED)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_baseline(self):
"""Include devices with readout priority Baseline in filters."""
return ReadoutPriority.BASELINE in self.readout_filter
@@ -265,7 +265,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.BASELINE)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_async(self):
"""Include devices with readout priority Async in filters."""
return ReadoutPriority.ASYNC in self.readout_filter
@@ -278,7 +278,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.ASYNC)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_continuous(self):
"""Include devices with readout priority continuous in filters."""
return ReadoutPriority.CONTINUOUS in self.readout_filter
@@ -291,7 +291,7 @@ class DeviceInputBase(BECWidget):
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
self.update_devices_from_filters()
@SafeProperty(bool)
@Property(bool)
def readout_on_request(self):
"""Include devices with readout priority OnRequest in filters."""
return ReadoutPriority.ON_REQUEST in self.readout_filter

View File

@@ -1,12 +1,11 @@
from bec_lib.callback_handler import EventType
from bec_lib.device import Signal
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property
from qtpy.QtCore import Property, Slot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO
@@ -50,18 +49,18 @@ class DeviceSignalInputBase(BECWidget):
self._device = None
self.get_bec_shortcuts()
self._signal_filter = set()
self._signal_filter = []
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
)
### Qt Slots ###
@SafeSlot(str)
@Slot(str)
def set_signal(self, signal: str):
"""
Set the signal.
@@ -77,10 +76,10 @@ class DeviceSignalInputBase(BECWidget):
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
)
@SafeSlot(str)
@Slot(str)
def set_device(self, device: str | None):
"""
Set the device. If device is not valid, device will be set to None which happens
Set the device. If device is not valid, device will be set to None which happpens
Args:
device(str): device name.
@@ -91,8 +90,8 @@ class DeviceSignalInputBase(BECWidget):
self._device = device
self.update_signals_from_filters()
@SafeSlot(dict, dict)
@SafeSlot()
@Slot(dict, dict)
@Slot()
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
@@ -108,32 +107,22 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device = self.get_device_object(self._device)
device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [(self._device, {})]
self._hinted_signals = [(self._device, {})]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
self._signals = [self._device]
FilterIO.set_selection(widget=self, selection=[self._device])
return
device_info = device._info["signals"]
def _update(kind: Kind):
return FilterIO.update_with_kind(
widget=self,
kind=kind,
signal_filter=self.signal_filter,
device_info=device_info,
device_name=self._device,
)
return [
signal
for signal, signal_info in device_info.items()
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
@@ -166,9 +155,9 @@ class DeviceSignalInputBase(BECWidget):
@include_hinted_signals.setter
def include_hinted_signals(self, value: bool):
if value:
self._signal_filter.add(Kind.hinted)
self._signal_filter.append(Kind.hinted)
else:
self._signal_filter.discard(Kind.hinted)
self._signal_filter.remove(Kind.hinted)
self.update_signals_from_filters()
@Property(bool)
@@ -179,9 +168,9 @@ class DeviceSignalInputBase(BECWidget):
@include_normal_signals.setter
def include_normal_signals(self, value: bool):
if value:
self._signal_filter.add(Kind.normal)
self._signal_filter.append(Kind.normal)
else:
self._signal_filter.discard(Kind.normal)
self._signal_filter.remove(Kind.normal)
self.update_signals_from_filters()
@Property(bool)
@@ -192,9 +181,9 @@ class DeviceSignalInputBase(BECWidget):
@include_config_signals.setter
def include_config_signals(self, value: bool):
if value:
self._signal_filter.add(Kind.config)
self._signal_filter.append(Kind.config)
else:
self._signal_filter.discard(Kind.config)
self._signal_filter.remove(Kind.config)
self.update_signals_from_filters()
### Properties and Methods ###
@@ -278,21 +267,11 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal(str): Signal to validate.
"""
for entry in self.signals:
if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
if signal in self.signals:
return True
return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)
def cleanup(self):
"""
Cleanup the widget.
"""
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
super().cleanup()

View File

@@ -22,19 +22,14 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices"]
ICON_NAME = "list_alt"
PLUGIN = True
device_selected = Signal(str)
device_reset = Signal()
device_config_update = Signal()
def __init__(
@@ -148,28 +143,8 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.device_selected.emit(input_text)
else:
self._is_valid_input = False
self.device_reset.emit()
self.update()
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
Extend validation so that previewsignal pseudodevices (labels like
``"eiger_preview"``) are accepted as valid choices.
The validation run only on device not on the previewsignal.
Args:
device: The text currently entered/selected.
Returns:
True if the device is a genuine BEC device *or* one of the
whitelisted previewsignal entries.
"""
idx = self.findText(device)
if idx >= 0 and isinstance(self.itemData(idx), tuple):
device = self.itemData(idx)[0] # type: ignore[assignment]
return super().validate_device(device)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

@@ -24,15 +24,11 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
device_filter: Device filter, name of the device class from BECDeviceFilter and ReadoutPriority. Check DeviceInputBase for more details.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
device_selected = Signal(str)
device_config_update = Signal()
@@ -55,7 +51,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
**kwargs,
):
self._callback_id = None
self.__is_valid_input = False
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.completer = QCompleter(self)
@@ -99,20 +95,6 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
self.textChanged.connect(self.check_validity)
self.check_validity(self.text())
@property
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
return self.__is_valid_input
@_is_valid_input.setter
def _is_valid_input(self, value: bool) -> None:
self.__is_valid_input = value
def on_device_update(self, action: str, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.

View File

@@ -1,13 +1,11 @@
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal
from qtpy.QtCore import QSize, Signal, Slot
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBase,
DeviceSignalInputBaseConfig,
)
@@ -25,11 +23,8 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["set_signal", "set_device", "signals"]
ICON_NAME = "list_alt"
PLUGIN = True
RPC = True
device_signal_changed = Signal(str)
@@ -37,7 +32,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self,
parent=None,
client=None,
config: DeviceSignalInputBaseConfig | None = None,
config: DeviceSignalInputBase = None,
gui_id: str | None = None,
device: str | None = None,
signal_filter: str | list[str] | None = None,
@@ -67,13 +62,9 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
if default is not None:
self.set_signal(default)
@SafeSlot()
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
def update_signals_from_filters(self):
"""Update the filters for the combobox"""
super().update_signals_from_filters(content, metadata)
super().update_signals_from_filters()
# pylint: disable=protected-access
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
if len(self._config_signals) > 0:
@@ -90,45 +81,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
Args:
obj_name (str): Object name of the signal.
Returns:
bool: True if the object name was found and set, False otherwise.
"""
for i in range(self.count()):
signal_data = self.itemData(i)
if signal_data and signal_data.get("obj_name") == obj_name:
self.setCurrentIndex(i)
return True
return False
def set_to_first_enabled(self) -> bool:
"""
Set the combobox to the first enabled item.
Returns:
bool: True if an enabled item was found and set, False otherwise.
"""
for i in range(self.count()):
if self.model().item(i).isEnabled():
self.setCurrentIndex(i)
return True
return False
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""
self.clear()
self.setItemText(0, "Select a device")
self.update_signals_from_filters()
self.device_signal_changed.emit("")
@SafeSlot(str)
@Slot(str)
def on_text_changed(self, text: str):
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
For a positioner, the readback value has to be renamed to the device name.
@@ -140,7 +93,11 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return
if self.validate_signal(text) is False:
return
self.device_signal_changed.emit(text)
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover

View File

@@ -24,12 +24,9 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"]
device_signal_changed = Signal(str)
PLUGIN = True
RPC = True
ICON_NAME = "vital_signs"
def __init__(
@@ -44,7 +41,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
arg_name: str | None = None,
**kwargs,
):
self.__is_valid_input = False
self._is_valid_input = False
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)
@@ -68,22 +65,8 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
self.set_device(device)
if default is not None:
self.set_signal(default)
self.textChanged.connect(self.check_validity)
self.check_validity(self.text())
@property
def _is_valid_input(self) -> bool:
"""
Check if the current value is a valid device name.
Returns:
bool: True if the current value is a valid device name, False otherwise.
"""
return self.__is_valid_input
@_is_valid_input.setter
def _is_valid_input(self, value: bool) -> None:
self.__is_valid_input = value
self.textChanged.connect(self.validate_device)
self.validate_device(self.text())
def get_current_device(self) -> object:
"""
@@ -148,9 +131,6 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
DeviceComboBox,
)
app = QApplication([])
set_theme("dark")
@@ -158,12 +138,6 @@ if __name__ == "__main__": # pragma: no cover
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
device_line_edit = DeviceComboBox()
device_line_edit.filter_to_positioner = True
signal_line_edit = SignalLineEdit()
device_line_edit.device_selected.connect(signal_line_edit.set_device)
layout.addWidget(device_line_edit)
layout.addWidget(signal_line_edit)
layout.addWidget(SignalLineEdit(device="samx"))
widget.show()
app.exec_()

View File

@@ -89,7 +89,6 @@ class ScanControl(BECWidget, QWidget):
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
# Create and set main layout
self._init_UI()
@@ -166,6 +165,7 @@ class ScanControl(BECWidget, QWidget):
self.layout.addStretch()
def _add_metadata_form(self):
self._metadata_form = ScanMetadata(parent=self)
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
@@ -203,40 +203,35 @@ class ScanControl(BECWidget, QWidget):
"""
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
"""
self.last_scan_found = False
if not self.toggle.checked:
return
enabled = self.toggle.checked
current_scan = self.comboBox_scan_selection.currentText()
history = (
self.client.connector.xread(
MessageEndpoints.scan_history(), from_start=True, user_id=self.object_name
)
or []
)
if enabled:
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, -1)
for scan in reversed(history):
scan_data = scan.get("data")
if not scan_data:
continue
if scan_data.scan_name != current_scan:
continue
ri = getattr(scan_data, "request_inputs", {}) or {}
args_list = ri.get("arg_bundle", [])
if args_list and self.arg_box:
self.arg_box.set_parameters(args_list)
inputs = ri.get("inputs", {})
kwargs = ri.get("kwargs", {})
merged = {**inputs, **kwargs}
if merged and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(merged)
self.last_scan_found = True
break
for scan in history:
scan_name = scan.content["info"]["request_blocks"][-1]["msg"].content["scan_type"]
if scan_name == current_scan:
args_dict = scan.content["info"]["request_blocks"][-1]["msg"].content[
"parameter"
]["args"]
args_list = []
for key, value in args_dict.items():
args_list.append(key)
args_list.extend(value)
if len(args_list) > 1 and self.arg_box is not None:
self.arg_box.set_parameters(args_list)
kwargs = scan.content["info"]["request_blocks"][-1]["msg"].content["parameter"][
"kwargs"
]
if kwargs and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(kwargs)
self.last_scan_found = True
break
else:
self.last_scan_found = False
else:
self.last_scan_found = False
@SafeProperty(str)
def current_scan(self):

View File

@@ -0,0 +1,870 @@
"""
BECConsole is a Qt widget that runs a Bash shell.
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
"""
import collections
import fcntl
import html
import os
import pty
import re
import signal
import sys
import time
import pyte
from pygments.token import Token
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Property as pyqtProperty
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot as Slot
ansi_colors = {
"black": "#000000",
"red": "#CD0000",
"green": "#00CD00",
"brown": "#996633", # Brown, replacing the yellow
"blue": "#0000EE",
"magenta": "#CD00CD",
"cyan": "#00CDCD",
"white": "#E5E5E5",
"brightblack": "#7F7F7F",
"brightred": "#FF0000",
"brightgreen": "#00FF00",
"brightyellow": "#FFFF00",
"brightblue": "#5C5CFF",
"brightmagenta": "#FF00FF",
"brightcyan": "#00FFFF",
"brightwhite": "#FFFFFF",
}
control_keys_mapping = {
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
}
normal_keys_mapping = {
QtCore.Qt.Key_Return: b"\n",
QtCore.Qt.Key_Space: b" ",
QtCore.Qt.Key_Enter: b"\n",
QtCore.Qt.Key_Tab: b"\t",
QtCore.Qt.Key_Backspace: b"\x08",
QtCore.Qt.Key_Home: b"\x47",
QtCore.Qt.Key_End: b"\x4f",
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0e",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",
QtCore.Qt.Key_F2: b"\x1b\x32",
QtCore.Qt.Key_F3: b"\x1b\x33",
QtCore.Qt.Key_F4: b"\x1b\x34",
QtCore.Qt.Key_F5: b"\x1b\x35",
QtCore.Qt.Key_F6: b"\x1b\x36",
QtCore.Qt.Key_F7: b"\x1b\x37",
QtCore.Qt.Key_F8: b"\x1b\x38",
QtCore.Qt.Key_F9: b"\x1b\x39",
QtCore.Qt.Key_F10: b"\x1b\x30",
QtCore.Qt.Key_F11: b"\x45",
QtCore.Qt.Key_F12: b"\x46",
}
def QtKeyToAscii(event):
"""
Convert the Qt key event to the corresponding ASCII sequence for
the terminal. This works fine for standard alphanumerical characters, but
most other characters require terminal specific control sequences.
The conversion below works for TERM="linux" terminals.
"""
if sys.platform == "darwin":
# special case for MacOS
# /!\ Qt maps ControlModifier to CMD
# CMD-C, CMD-V for copy/paste
# CTRL-C and other modifiers -> key mapping
if event.modifiers() == QtCore.Qt.MetaModifier:
if event.key() == Qt.Key_Backspace:
return control_keys_mapping.get(Qt.Key_W)
return control_keys_mapping.get(event.key())
elif event.modifiers() == QtCore.Qt.ControlModifier:
if event.key() == Qt.Key_C:
# copy
return "copy"
elif event.key() == Qt.Key_V:
# paste
return "paste"
return None
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
if event.modifiers() == QtCore.Qt.ControlModifier:
return control_keys_mapping.get(event.key())
else:
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, cols, rows, historyLength):
super().__init__(cols, rows, historyLength, ratio=1 / rows)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request (for example),
this can be for other requests
"""
try:
os.write(self._fd, data.encode("utf-8"))
except (IOError, OSError):
pass
def resize(self, lines, columns):
lines = lines or self.lines
columns = columns or self.columns
if lines == self.lines and columns == self.columns:
return # No changes.
self.dirty.clear()
self.dirty.update(range(lines))
self.save_cursor()
if lines < self.lines:
if lines <= self.cursor.y:
nlines_to_move_up = self.lines - lines
for i in range(nlines_to_move_up):
line = self.buffer[i] # .pop(0)
self.history.top.append(line)
self.cursor_position(0, 0)
self.delete_lines(nlines_to_move_up)
self.restore_cursor()
self.cursor.y -= nlines_to_move_up
else:
self.restore_cursor()
self.lines, self.columns = lines, columns
self.history = History(
self.history.top,
self.history.bottom,
1 / self.lines,
self.history.size,
self.history.position,
)
self.set_margins()
class Backend(QtCore.QObject):
"""
Poll Bash.
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
file descriptor of the Bash terminal.
"""
# Signals to communicate with ``_TerminalWidget``.
dataReady = pyqtSignal(object)
processExited = pyqtSignal()
def __init__(self, fd, cols, rows):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, cols, rows, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._fd_readable)
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
self.processExited.emit()
self.notifier.setEnabled(False)
return
# Feed output into Pyte's state machine and send the new screen
# output to the GUI
self.stream.feed(out)
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QWidget):
"""Container widget for the terminal text area"""
PLUGIN = True
ICON_NAME = "terminal"
prompt = pyqtSignal(bool)
def __init__(self, parent=None, cols=132):
super().__init__(parent)
self.term = _TerminalWidget(self, cols, rows=43)
self.term.prompt.connect(self.prompt) # forward signal from term to this widget
self.scroll_bar = QScrollBar(Qt.Vertical, self)
# self.scroll_bar.hide()
layout = QHBoxLayout(self)
layout.addWidget(self.term)
layout.addWidget(self.scroll_bar)
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
layout.setContentsMargins(0, 0, 0, 0)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
pal = QPalette()
self.set_bgcolor(pal.window().color())
self.set_fgcolor(pal.windowText().color())
self.term.set_scroll_bar(self.scroll_bar)
self.set_cmd("bec --nogui")
self._check_designer_timer = QTimer()
self._check_designer_timer.timeout.connect(self.check_designer)
self._check_designer_timer.start(1000)
def minimumSizeHint(self):
size = self.term.sizeHint()
size.setWidth(size.width() + self.scroll_bar.width())
return size
def sizeHint(self):
return self.minimumSizeHint()
def check_designer(self, calls={"n": 0}):
calls["n"] += 1
if self.term.fd is not None:
# already started
self._check_designer_timer.stop()
elif self.window().windowTitle().endswith("[Preview]"):
# assuming Designer preview -> start
self._check_designer_timer.stop()
self.term.start()
elif calls["n"] >= 3:
# assuming not in Designer -> stop checking
self._check_designer_timer.stop()
def get_rows(self):
return self.term.rows
def set_rows(self, rows):
self.term.rows = rows
self.adjustSize()
self.updateGeometry()
def get_cols(self):
return self.term.cols
def set_cols(self, cols):
self.term.cols = cols
self.adjustSize()
self.updateGeometry()
def get_bgcolor(self):
return QColor.fromString(self.term.bg_color)
def set_bgcolor(self, color):
self.term.bg_color = color.name(QColor.HexRgb)
def get_fgcolor(self):
return QColor.fromString(self.term.fg_color)
def set_fgcolor(self, color):
self.term.fg_color = color.name(QColor.HexRgb)
def get_cmd(self):
return self.term._cmd
def set_cmd(self, cmd):
self.term._cmd = cmd
if self.term.fd is None:
# not started yet
self.term.clear()
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
def start(self, deactivate_ctrl_d=True):
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text, hit_return=False):
"""Push some text to the terminal"""
return self.term.push(text, hit_return=hit_return)
def execute_command(self, command):
self.push(command, hit_return=True)
def set_prompt_tokens(self, *tokens):
"""Prepare regexp to identify prompt, based on tokens
Tokens are returned from get_ipython().prompts.in_prompt_tokens()
"""
regex_parts = []
for token_type, token_value in tokens:
if token_type == Token.PromptNum: # Handle dynamic prompt number
regex_parts.append(r"[\d\?]+") # Match one or more digits or '?'
else:
# Escape other prompt parts (e.g., "In [", "]: ")
if not token_value:
regex_parts.append(".+?") # arbitrary string
else:
regex_parts.append(re.escape(token_value))
# Combine into a single regex
prompt_pattern = "".join(regex_parts)
self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")
def terminate(self, timeout=10):
self.term.stop(timeout=timeout)
def send_ctrl_c(self, timeout=None):
self.term.send_ctrl_c(timeout)
cols = pyqtProperty(int, get_cols, set_cols)
rows = pyqtProperty(int, get_rows, set_rows)
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
cmd = pyqtProperty(str, get_cmd, set_cmd)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
prompt = pyqtSignal(bool)
def __init__(self, parent, cols=125, rows=50, **kwargs):
# regexp to match prompt
self._prompt_re = None
# last prompt
self._prompt_str = None
# process pid
self.pid = None
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
# command to execute
self._cmd = ""
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Default colors
pal = QPalette()
self._fg_color = pal.text().color().name()
self._bg_color = pal.base().color().name()
# Specify the terminal size in terms of lines and columns.
self._rows = rows
self._cols = cols
self.output = collections.deque()
super().__init__(parent)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_bar = None
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
self.setCursorWidth(char_width)
self.adjustSize()
self.updateGeometry()
self.update_stylesheet()
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, hexcolor):
self._bg_color = hexcolor
self.update_stylesheet()
@property
def fg_color(self):
return self._fg_color
@fg_color.setter
def fg_color(self, hexcolor):
self._fg_color = hexcolor
self.update_stylesheet()
def update_stylesheet(self):
self.setStyleSheet(
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
)
@property
def rows(self):
return self._rows
@rows.setter
def rows(self, rows: int):
if self.backend is None:
# not initialized yet, ok to change
self._rows = rows
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change rows after console is started.")
@property
def cols(self):
return self._cols
@cols.setter
def cols(self, cols: int):
if self.fd is None:
# not initialized yet, ok to change
self._cols = cols
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change cols after console is started.")
def start(self, deactivate_ctrl_d: bool = False):
self._deactivate_ctrl_d = deactivate_ctrl_d
self.update_term_size()
# Start the Bash process
self.pid, self.fd = self.fork_shell()
if self.fd:
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.cols, self.rows)
self.backend.dataReady.connect(self.data_ready)
self.backend.processExited.connect(self.process_exited)
else:
self.process_exited()
def process_exited(self):
self.fd = None
self.clear()
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
self.setReadOnly(True)
def send_ctrl_c(self, wait_prompt=True, timeout=None):
"""Send CTRL-C to the process
If wait_prompt=True (default), wait for a new prompt after CTRL-C
If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
"""
os.kill(self.pid, signal.SIGINT)
if wait_prompt:
timeout_error = False
if timeout:
def set_timeout_error():
nonlocal timeout_error
timeout_error = True
timeout_timer = QTimer()
timeout_timer.singleShot(timeout * 1000, set_timeout_error)
while self._prompt_str is None:
QApplication.instance().process_events()
if timeout_error:
raise TimeoutError(
f"CTRL-C: could not get back to prompt after {timeout} seconds."
)
def _is_running(self):
if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
return True
return False
def stop(self, kill=True, timeout=None):
"""Stop the running process
SIGTERM is the default signal for terminating processes.
If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
"""
# try to exit gracefully
os.kill(self.pid, signal.SIGTERM)
# wait until process is truly dead
t0 = time.perf_counter()
while self._is_running():
time.sleep(1)
if timeout is not None and time.perf_counter() - t0 > timeout:
# still alive after 'timeout' seconds
if kill:
# send SIGKILL and make a last check in loop
os.kill(self.pid, signal.SIGKILL)
kill = False
else:
# still running after timeout...
raise TimeoutError(
f"Could not terminate process with pid: {self.pid} within timeout"
)
self.process_exited()
def data_ready(self, screen):
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
This method is triggered via a signal from ``Backend``.
"""
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def minimumSizeHint(self):
"""Return minimum size for current cols and rows"""
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
width = char_width * self.cols
height = char_height * self.rows
return QSize(width, height)
def sizeHint(self):
return self.minimumSizeHint()
def set_scroll_bar(self, scroll_bar):
self.scroll_bar = scroll_bar
self.scroll_bar.setMinimum(0)
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": -1}):
if self.backend is None:
return
if old["value"] == -1:
old["value"] = self.scroll_bar.maximum()
if value <= old["value"]:
# scroll up
# value is number of lines from the start
nlines = old["value"] - value
# history ratio gives prev_page == 1 line
for i in range(nlines):
self.backend.screen.prev_page()
else:
# scroll down
nlines = value - old["value"]
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.redraw_screen()
def adjust_scroll_bar(self):
sb = self.scroll_bar
sb.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
sb.setMaximum(tmp if tmp > 0 else 0)
sb.setSliderPosition(tmp if tmp > 0 else 0)
# if tmp > 0:
# # show scrollbar, but delayed - prevent recursion with widget size change
# QTimer.singleShot(0, scrollbar.show)
# else:
# QTimer.singleShot(0, scrollbar.hide)
sb.valueChanged.connect(self.scroll_value_change)
def write(self, data):
try:
os.write(self.fd, data)
except (IOError, OSError):
self.process_exited()
@Slot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
if self.fd is None:
# not started
return
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
and event.modifiers() == QtCore.Qt.ControlModifier
and event.key() == QtCore.Qt.Key_D
):
return None
code = QtKeyToAscii(event)
if code == "copy":
# MacOS only: CMD-C handling
self.copy()
elif code == "paste":
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
self.write(code)
def push(self, text, hit_return=False):
"""
Write 'text' to terminal
"""
self.write(text.encode("utf-8"))
if hit_return:
self.write(b"\n")
def contextMenuEvent(self, event):
if self.fd is None:
return
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
if "opy" in action.text():
# redefine text without shortcut
# since it probably clashes with control codes (like CTRL-C etc)
action.setText("Copy")
continue
if "aste" in action.text():
# redefine text without shortcut
action.setText("Paste")
# paste -> have to insert with self.push
action.triggered.connect(self._push_clipboard)
continue
menu.removeAction(action)
menu.exec_(event.globalPos())
def _push_clipboard(self):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def move_cursor(self):
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
def mouseReleaseEvent(self, event):
if self.fd is None:
return
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
if clipboard.supportsSelection():
self.push(clipboard.text(QClipboard.Selection))
return None
elif event.button() == Qt.LeftButton:
# left button click
textCursor = self.textCursor()
if textCursor.selectedText():
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', move scrollbar to end
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
self.move_cursor()
return None
return super().mouseReleaseEvent(event)
def redraw_screen(self):
"""
Render the screen as formatted text into the widget.
"""
screen = self.backend.screen
# Clear the widget
if screen.dirty:
self.clear()
while len(self.output) < (max(screen.dirty) + 1):
self.output.append("")
while len(self.output) > (max(screen.dirty) + 1):
self.output.pop()
# Prepare the HTML output
for line_no in screen.dirty:
line = text = ""
style = old_style = ""
old_idx = 0
for idx, ch in screen.buffer[line_no].items():
text += " " * (idx - old_idx - 1)
old_idx = idx
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
text = ""
old_style = style
text += ch.data
if style:
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
# do a check at the cursor position:
# it is possible x pos > output line length,
# for example if last escape codes are "cursor forward" past end of text,
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
if line_no == screen.cursor.y:
llen = len(screen.buffer[line_no])
if llen < screen.cursor.x:
line += " " * (screen.cursor.x - llen)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
if self._prompt_re is not None:
text_buf = self.toPlainText()
prompt = self._prompt_re.search(text_buf)
if prompt is None:
if self._prompt_str:
self.prompt.emit(False)
self._prompt_str = None
else:
prompt_str = prompt.string.rstrip()
if prompt_str != self._prompt_str:
self._prompt_str = prompt_str
self.prompt.emit(True)
# did updates, all clean
screen.dirty.clear()
def update_term_size(self):
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
self._cols = int(self.width() / char_width)
self._rows = int(self.height() / char_height)
def resizeEvent(self, event):
self.update_term_size()
if self.fd:
self.backend.screen.resize(self._rows, self._cols)
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def wheelEvent(self, event):
if not self.fd:
return
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.redraw_screen()
def fork_shell(self):
"""
Fork the current process and execute bec in shell.
"""
try:
pid, fd = pty.fork()
except (IOError, OSError):
return False
if pid == 0:
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
os.putenv("COLUMNS", str(self.cols))
os.putenv("LINES", str(self.rows))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if not self._cmd:
self._cmd = os.environ["SHELL"]
cmd = self._cmd
if isinstance(cmd, str):
cmd = cmd.split()
try:
os.execvp(cmd[0], cmd)
except (IOError, OSError):
pass
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
return pid, fd
if __name__ == "__main__":
import os
import sys
from qtpy import QtGui, QtWidgets
# Create the Qt application and console.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole"
mainwin.setWindowTitle(title)
console = BECConsole(mainwin)
mainwin.setCentralWidget(console)
def check_prompt(at_prompt):
if at_prompt:
print("NEW PROMPT")
else:
print("EXECUTING SOMETHING...")
console.set_prompt_tokens(
(Token.OutPromptNum, ""),
(Token.Prompt, ""), # will match arbitrary string,
(Token.Prompt, " ["),
(Token.PromptNum, "3"),
(Token.Prompt, "/"),
(Token.PromptNum, "1"),
(Token.Prompt, "] "),
(Token.Prompt, ""),
)
console.prompt.connect(check_prompt)
console.start()
# Show widget and launch Qt's event loop.
mainwin.show()
sys.exit(app.exec_())

View File

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

View File

@@ -1,39 +1,43 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor import SBBMonitor
from bec_widgets.widgets.editors.console.console import BECConsole
DOM_XML = """
<ui language='c++'>
<widget class='SBBMonitor' name='sbb_monitor'>
<widget class='BECConsole' name='bec_console'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SBBMonitor(parent)
t = BECConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
return "BEC Console"
def icon(self):
return designer_material_icon(SBBMonitor.ICON_NAME)
return designer_material_icon(BECConsole.ICON_NAME)
def includeFile(self):
return "sbb_monitor"
return "bec_console"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -45,10 +49,10 @@ class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "SBBMonitor"
return "BECConsole"
def toolTip(self):
return ""
return "A terminal-like vt100 widget."
def whatsThis(self):
return self.toolTip()

View File

@@ -6,9 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor_plugin import SBBMonitorPlugin
from bec_widgets.widgets.editors.console.console_plugin import BECConsolePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SBBMonitorPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -2,9 +2,7 @@ from __future__ import annotations
from typing import Any
from qtpy import QtWidgets
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
@@ -15,9 +13,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
_NOT_SET = object()
from bec_widgets.utils.error_popups import SafeSlot
class DictBackedTableModel(QAbstractTableModel):
@@ -29,7 +25,6 @@ class DictBackedTableModel(QAbstractTableModel):
data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__()
self._data: list[list[str]] = data
self._default = _NOT_SET
self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring
@@ -50,15 +45,8 @@ class DictBackedTableModel(QAbstractTableModel):
def data(self, index, role=Qt.ItemDataRole):
if index.isValid():
if role in [
Qt.ItemDataRole.DisplayRole,
Qt.ItemDataRole.EditRole,
Qt.ItemDataRole.ToolTipRole,
]:
try:
return str(self._data[index.row()][index.column()])
except IndexError:
return None
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
return str(self._data[index.row()][index.column()])
def setData(self, index, value, role):
if role == Qt.ItemDataRole.EditRole:
@@ -69,12 +57,6 @@ class DictBackedTableModel(QAbstractTableModel):
return True
return False
def replaceData(self, data: dict):
self.delete_rows(list(range(len(self._data))))
self.resetInternalData()
self._data = [[str(k), str(v)] for k, v in data.items()]
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 1))
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
@@ -84,7 +66,7 @@ class DictBackedTableModel(QAbstractTableModel):
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
self._data[i][0] = ""
self.dataChanged.emit(self.index(i, 0), self.index(i, 1))
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
def _other_keys(self, row: int):
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
@@ -113,74 +95,45 @@ class DictBackedTableModel(QAbstractTableModel):
@SafeSlot()
def add_row(self):
self.insertRow(self.rowCount())
self.dataChanged.emit(self.index(self.rowCount(), 0), self.index(self.rowCount(), 1), 0)
@SafeSlot(list)
def delete_rows(self, rows: list[int]):
# delete from the end so indices stay correct
for row in sorted(rows, reverse=True):
self.dataChanged.emit(self.index(row, 0), self.index(row, 1), 0)
self.removeRows(row, 1, QModelIndex())
def set_default(self, value: dict | None):
self._default = value
def dump_dict(self):
if self._data in [[], [[]], [["", ""]]]:
if self._default is not _NOT_SET:
return self._default
if self._data == [[]]:
return {}
return dict(self._data)
def length(self):
return len(self._data)
class DictBackedTable(QWidget):
delete_rows = Signal(list)
data_changed = Signal(dict)
data_updated = Signal()
def __init__(
self,
parent: QWidget | None = None,
initial_data: list[list[str]] = [],
autoscale_to_data: bool = True,
):
def __init__(self, initial_data: list[list[str]]):
"""Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__(parent)
super().__init__()
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._layout.setContentsMargins(0, 0, 0, 0)
self._table_model = DictBackedTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._min_lines = 3
self.set_height_in_lines(len(initial_data))
self._table_view.setSizePolicy(
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
)
self._table_view.setAlternatingRowColors(True)
self._table_view.setUniformRowHeights(True)
self._table_view.setWordWrap(False)
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
self.autoscale = autoscale_to_data
if self.autoscale:
self.data_changed.connect(self.scale_to_data)
self._layout.addWidget(self._table_view)
self._button_holder = QWidget()
self._buttons = QVBoxLayout()
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._layout.addLayout(self._buttons)
self._add_button = QPushButton("+")
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
@@ -190,21 +143,11 @@ class DictBackedTable(QWidget):
self._add_button.clicked.connect(self._table_model.add_row)
self._remove_button.clicked.connect(self.delete_selected_rows)
self.delete_rows.connect(self._table_model.delete_rows)
self._table_model.dataChanged.connect(self._emit_data_updated)
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
def set_default(self, value: dict | None):
self._table_model.set_default(value)
def set_button_visibility(self, value: bool):
self._button_holder.setVisible(value)
@SafeSlot()
def clear(self):
self._table_model.replaceData({})
def replace_data(self, data: dict | None):
self._table_model.replaceData(data or {})
def _emit_data_updated(self, *args, **kwargs):
"""Just to swallow the args"""
self.data_updated.emit()
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""
@@ -224,29 +167,6 @@ class DictBackedTable(QWidget):
keys (list[str]): list of keys which are forbidden."""
self._table_model.update_disallowed_keys(keys)
def set_height_in_lines(self, lines: int):
self._table_view.setMaximumHeight(
int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines))
)
@SafeSlot()
@SafeSlot(dict)
def scale_to_data(self, *_):
self.set_height_in_lines(self._table_model.length())
@SafeProperty(bool)
def autoscale(self): # type: ignore
return self._autoscale
@autoscale.setter
def autoscale(self, autoscale: bool):
self._autoscale = autoscale
if self._autoscale:
self.scale_to_data()
self.data_changed.connect(self.scale_to_data)
else:
self.data_changed.disconnect(self.scale_to_data)
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
@@ -254,6 +174,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@@ -1,15 +0,0 @@
from bec_widgets.widgets.editors.website.website import WebsiteWidget
class SBBMonitor(WebsiteWidget):
"""
A widget to display the SBB monitor website.
"""
PLUGIN = True
ICON_NAME = "train"
USER_ACCESS = []
def __init__(self, parent=None, **kwargs):
url = "https://free.oevplus.ch/monitor/?viewType=splitView&layout=1&showClock=true&showPerron=true&stationGroup1Title=Villigen%2C%20PSI%20West&stationGroup2Title=Siggenthal-Würenlingen&station_1_id=85%3A3592&station_1_name=Villigen%2C%20PSI%20West&station_1_quantity=5&station_1_group=1&station_2_id=85%3A3502&station_2_name=Siggenthal-Würenlingen&station_2_quantity=5&station_2_group=2"
super().__init__(parent=parent, url=url, **kwargs)

View File

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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import sys
from decimal import Decimal
from math import copysign, inf, nextafter
from math import inf, nextafter
from typing import TYPE_CHECKING, TypeVar, get_args
from annotated_types import Ge, Gt, Le, Lt
@@ -23,19 +23,16 @@ _MAXFLOAT = sys.float_info.max
T = TypeVar("T", int, float, Decimal)
def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]:
def _nextafter(x, y):
return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
_min = _MININT if type_ is int else _MINFLOAT
_max = _MAXINT if type_ is int else _MAXFLOAT
for md in info.metadata:
if isinstance(md, Ge):
_min = type_(md.ge) # type: ignore
if isinstance(md, Gt):
_min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
if isinstance(md, Lt):
_max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
if isinstance(md, Le):
_max = type_(md.le) # type: ignore
return _min, _max # type: ignore
@@ -67,6 +64,4 @@ def field_default(info: FieldInfo):
def clearable_required(info: FieldInfo):
return type(None) in get_args(info.annotation) or (
info.is_required() and info.default is PydanticUndefined
)
return type(None) in get_args(info.annotation) or info.is_required()

View File

@@ -16,9 +16,6 @@ logger = bec_logger.logger
class ScanMetadata(PydanticModelForm):
RPC = False
def __init__(
self,
parent=None,
@@ -39,18 +36,16 @@ class ScanMetadata(PydanticModelForm):
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame(
parent, "Additional metadata", expanded=False
)
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(parent, initial_extras or [])
self._additional_metadata = DictBackedTable(initial_extras or [])
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_changed.connect(self.validate_form)
self._additional_metadata.data_updated.connect(self.validate_form)
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -132,7 +127,6 @@ if __name__ == "__main__": # pragma: no cover
w.setLayout(layout)
scan_metadata = ScanMetadata(
parent=w,
scan_name="grid_scan",
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
)

View File

@@ -149,6 +149,7 @@ _web_console_registry = WebConsoleRegistry()
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)

View File

@@ -8,6 +8,7 @@ from bec_widgets.utils.bec_widget import BECWidget
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,9 @@ logger = bec_logger.logger
# noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config
parent_id: str | None = Field(None, description="The parent plot of the image.")
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("plasma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.")
@@ -40,7 +43,6 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
class ImageItem(BECConnector, pg.ImageItem):
RPC = True
USER_ACCESS = [
"color_map",
@@ -67,13 +69,12 @@ class ImageItem(BECConnector, pg.ImageItem):
]
vRangeChangedManually = Signal(tuple)
removed = Signal(str)
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image=None, # FIXME: rename to parent
parent_image=None,
**kwargs,
):
if config is None:
@@ -273,8 +274,6 @@ class ImageItem(BECConnector, pg.ImageItem):
self.buffer = []
self.max_len = 0
def remove(self, emit: bool = True):
def remove(self):
self.parent().disconnect_monitor(self.config.monitor)
self.clear()
super().remove()
if emit:
self.removed.emit(self.objectName())

View File

@@ -1,37 +0,0 @@
import pyqtgraph as pg
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.plots.plot_base import BECViewBox
class ImageROIPlot(RoundedFrame):
"""
A widget for displaying an image with a region of interest (ROI) overlay.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.content_widget = pg.GraphicsLayoutWidget(self)
self.layout.addWidget(self.content_widget)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.content_widget.addItem(self.plot_item)
self.curve_color = "w"
self.apply_plot_widget_style()
def apply_theme(self, theme: str):
if theme == "dark":
self.curve_color = "w"
else:
self.curve_color = "k"
for curve in self.plot_item.curves:
curve.setPen(pg.mkPen(self.curve_color, width=3))
super().apply_theme(theme)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.plot_item.vb.menu.close()
self.plot_item.vb.menu.deleteLater()
self.plot_item.ctrlMenu.close()
self.plot_item.ctrlMenu.deleteLater()

View File

@@ -1,485 +0,0 @@
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from bec_qthemes import material_icon
from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QHBoxLayout,
QHeaderView,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils import BECDispatcher, ConnectionConfig
from bec_widgets.utils.toolbars.actions import WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.plots.roi.image_roi import (
BaseROI,
CircularROI,
EllipticalROI,
RectangularROI,
ROIController,
)
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING:
from bec_widgets.widgets.plots.image.image import Image
class ROILockButton(QToolButton):
"""Keeps its icon and checked state in sync with a single ROI."""
def __init__(self, roi: BaseROI, parent=None):
super().__init__(parent)
self.setCheckable(True)
self._roi = roi
self.clicked.connect(self._toggle)
roi.movableChanged.connect(lambda _: self._sync())
self._sync()
def _toggle(self):
# checked -> locked -> movable = False
self._roi.movable = not self.isChecked()
def _sync(self):
movable = self._roi.movable
self.setChecked(not movable)
icon = "lock_open_right" if movable else "lock"
self.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
class ROIPropertyTree(BECWidget, QWidget):
"""
Two-column tree: [ROI] [Properties]
- Top-level: ROI name (editable) + color button.
- Children: type, line-width (spin box), coordinates (auto-updating).
Args:
image_widget (Image): The main Image widget that displays the ImageItem.
Provides ``plot_item`` and owns an ROIController already.
controller (ROIController, optional): Optionally pass an external controller.
If None, the manager uses ``image_widget.roi_controller``.
parent (QWidget, optional): Parent widget. Defaults to None.
"""
PLUGIN = False
RPC = False
COL_ACTION, COL_ROI, COL_PROPS = range(3)
DELETE_BUTTON_COLOR = "#CC181E"
def __init__(
self,
*,
parent: QWidget = None,
image_widget: Image,
controller: ROIController | None = None,
):
super().__init__(
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
)
if controller is None:
# Use the controller already belonging to the Image widget
controller = getattr(image_widget, "roi_controller", None)
if controller is None:
controller = ROIController()
image_widget.roi_controller = controller
self.image_widget = image_widget
self.plot = image_widget.plot_item
self.controller = controller
self.roi_items: dict[BaseROI, QTreeWidgetItem] = {}
self.layout = QVBoxLayout(self)
self._init_toolbar()
self._init_tree()
# connect controller
self.controller.roiAdded.connect(self._on_roi_added)
self.controller.roiRemoved.connect(self._on_roi_removed)
self.controller.cleared.connect(self.tree.clear)
# initial load
for r in self.controller.rois:
self._on_roi_added(r)
self.tree.collapseAll()
# --------------------------------------------------------------------- UI
def _init_toolbar(self):
tb = self.toolbar = ModularToolBar(self, orientation="horizontal")
self._draw_actions: dict[str, MaterialIconAction] = {}
# --- ROI draw actions (toggleable) ---
tb.components.add_safe(
"roi_rectangle",
MaterialIconAction("add_box", "Add Rect ROI", checkable=True, parent=self),
)
tb.components.add_safe(
"roi_circle",
MaterialIconAction("add_circle", "Add Circle ROI", checkable=True, parent=self),
)
tb.components.add_safe(
"roi_ellipse",
MaterialIconAction("vignette", "Add Ellipse ROI", checkable=True, parent=self),
)
bundle = ToolbarBundle("roi_draw", tb.components)
bundle.add_action("roi_rectangle")
bundle.add_action("roi_circle")
bundle.add_action("roi_ellipse")
tb.add_bundle(bundle)
self._draw_actions = {
"rect": tb.components.get_action("roi_rectangle"),
"circle": tb.components.get_action("roi_circle"),
"ellipse": tb.components.get_action("roi_ellipse"),
}
for mode, act in self._draw_actions.items():
act.action.toggled.connect(lambda on, m=mode: self._on_draw_action_toggled(m, on))
# Expand/Collapse toggle
self.expand_toggle = MaterialIconAction(
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
)
tb.components.add_safe("expand_toggle", self.expand_toggle)
def _exp_toggled(on: bool):
if on:
# switched to expanded state
self.tree.expandAll()
new_icon = material_icon("unfold_less", size=(20, 20), convert_to_pixmap=False)
else:
# collapsed state
self.tree.collapseAll()
new_icon = material_icon("unfold_more", size=(20, 20), convert_to_pixmap=False)
self.expand_toggle.action.setIcon(new_icon)
self.expand_toggle.action.toggled.connect(_exp_toggled)
self.expand_toggle.action.setChecked(False)
# Lock/Unlock all ROIs
self.lock_all_action = MaterialIconAction(
"lock_open_right", "Lock/Unlock all ROIs", checkable=True, parent=self
)
tb.components.add_safe("lock_unlock_all", self.lock_all_action)
def _lock_all(checked: bool):
# checked -> everything locked (movable = False)
for r in self.controller.rois:
r.movable = not checked
new_icon = material_icon(
"lock" if checked else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.lock_all_action.action.setIcon(new_icon)
self.lock_all_action.action.toggled.connect(_lock_all)
# colormap widget
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
tb.components.add_safe("roi_tree_spacer", WidgetAction(widget=QWidget()))
tb.components.add_safe("roi_tree_cmap", WidgetAction(widget=self.cmap))
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
self.layout.addWidget(tb)
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
bundle = ToolbarBundle("roi_tools", tb.components)
bundle.add_action("expand_toggle")
bundle.add_action("lock_unlock_all")
bundle.add_action("roi_tree_spacer")
bundle.add_action("roi_tree_cmap")
tb.add_bundle(bundle)
tb.show_bundles(["roi_draw", "roi_tools"])
# ROI drawing state
self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None
self._roi_start_pos = None # QPointF in image coords
self._temp_roi = None # live ROI being resized while dragging
# capture mouse events on the plot scene
self.plot.scene().installEventFilter(self)
def _init_tree(self):
self.tree = QTreeWidget()
self.tree.setColumnCount(3)
self.tree.setHeaderLabels(["Actions", "ROI", "Properties"])
self.tree.header().setSectionResizeMode(self.COL_ACTION, QHeaderView.ResizeToContents)
self.tree.headerItem().setText(self.COL_ACTION, "Actions") # blank header text
self.tree.itemChanged.connect(self._on_item_edited)
self.layout.addWidget(self.tree)
################################################################################
# Helper functions
################################################################################
# --------------------------------------------------------------------- formatting
@staticmethod
def _format_coord_text(value) -> str:
"""
Consistently format a coordinate value for display.
"""
if isinstance(value, (tuple, list)):
return "(" + ", ".join(f"{v:.2f}" for v in value) + ")"
if isinstance(value, (int, float)):
return f"{value:.2f}"
return str(value)
def _set_roi_draw_mode(self, mode: str | None):
# Update toolbar actions so that only the selected mode is checked
for m, act in self._draw_actions.items():
act.action.blockSignals(True)
act.action.setChecked(m == mode)
act.action.blockSignals(False)
self._roi_draw_mode = mode
self._roi_start_pos = None
# remove any unfinished temp ROI
if self._temp_roi is not None:
self.plot.removeItem(self._temp_roi)
self._temp_roi = None
def _on_draw_action_toggled(self, mode: str, checked: bool):
if checked:
# Activate selected mode
self._set_roi_draw_mode(mode)
else:
# If the active mode is being unchecked, clear mode
if self._roi_draw_mode == mode:
self._set_roi_draw_mode(None)
def eventFilter(self, obj, event):
if self._roi_draw_mode is None:
return super().eventFilter(obj, event)
if event.type() == QEvent.GraphicsSceneMousePress and event.button() == Qt.LeftButton:
self._roi_start_pos = self.plot.vb.mapSceneToView(event.scenePos())
if self._roi_draw_mode == "rect":
self._temp_roi = RectangularROI(
pos=[self._roi_start_pos.x(), self._roi_start_pos.y()],
size=[5, 5],
parent_image=self.image_widget,
resize_handles=False,
)
elif self._roi_draw_mode == "circle":
self._temp_roi = CircularROI(
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
size=[5, 5],
parent_image=self.image_widget,
)
elif self._roi_draw_mode == "ellipse":
self._temp_roi = EllipticalROI(
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
size=[5, 5],
parent_image=self.image_widget,
)
self.plot.addItem(self._temp_roi)
return True
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
pos = self.plot.vb.mapSceneToView(event.scenePos())
dx = pos.x() - self._roi_start_pos.x()
dy = pos.y() - self._roi_start_pos.y()
if self._roi_draw_mode == "rect":
self._temp_roi.setSize([dx, dy])
elif self._roi_draw_mode == "circle":
r = max(
1, math.hypot(dx, dy)
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
d = 2 * r # diameter
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
self._temp_roi.setSize([d, d])
elif self._roi_draw_mode == "ellipse":
# Safeguard: enforce a minimum ellipse width/height of 2 px
min_dim = 2.0
w = dx if abs(dx) >= min_dim else math.copysign(min_dim, dx or 1.0)
h = dy if abs(dy) >= min_dim else math.copysign(min_dim, dy or 1.0)
self._temp_roi.setSize([w, h])
return True
elif (
event.type() == QEvent.GraphicsSceneMouseRelease
and event.button() == Qt.LeftButton
and self._temp_roi is not None
):
# finalize ROI
final_roi = self._temp_roi
self._temp_roi = None
self._set_roi_draw_mode(None)
# register via controller
self.controller.add_roi(final_roi)
return True
return super().eventFilter(obj, event)
# --------------------------------------------------------- controller slots
def _on_roi_added(self, roi: BaseROI):
# check the global setting from the toolbar
if self.lock_all_action.action.isChecked():
roi.movable = False
# parent row with blank action column, name in ROI column
parent = QTreeWidgetItem(self.tree, ["", "", ""])
parent.setText(self.COL_ROI, roi.label)
parent.setFlags(parent.flags() | Qt.ItemIsEditable)
# --- actions widget (lock/unlock + delete) ---
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(3)
# lock / unlock toggle
lock_btn = ROILockButton(roi, parent=self)
actions_layout.addWidget(lock_btn)
# delete button
del_btn = QToolButton()
delete_icon = material_icon(
"delete",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.DELETE_BUTTON_COLOR,
)
del_btn.setIcon(delete_icon)
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
actions_layout.addWidget(del_btn)
# install composite widget into the tree
self.tree.setItemWidget(parent, self.COL_ACTION, actions_widget)
# color button
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
color_btn.color_changed.connect(
lambda new_color, r=roi: setattr(r, "line_color", new_color)
)
# child rows (3 columns: action, ROI, properties)
QTreeWidgetItem(parent, ["", "Type", roi.__class__.__name__])
width_item = QTreeWidgetItem(parent, ["", "Line width", ""])
width_spin = QSpinBox()
width_spin.setRange(1, 50)
width_spin.setValue(roi.line_width)
self.tree.setItemWidget(width_item, self.COL_PROPS, width_spin)
width_spin.valueChanged.connect(lambda v, r=roi: setattr(r, "line_width", v))
# --- Step 2: Insert separate coordinate rows (one per value)
coord_rows = {}
coords = roi.get_coordinates(typed=True)
for key, value in coords.items():
# Human-readable label: “center x” from “center_x”, etc.
label = key.replace("_", " ").title()
val_text = self._format_coord_text(value)
row = QTreeWidgetItem(parent, ["", label, val_text])
coord_rows[key] = row
# keep dict refs
self.roi_items[roi] = parent
# --- Step 3: Update coordinates on ROI movement
def _update_coords():
c_dict = roi.get_coordinates(typed=True)
for k, row in coord_rows.items():
if k in c_dict:
val = c_dict[k]
row.setText(self.COL_PROPS, self._format_coord_text(val))
if isinstance(roi, RectangularROI):
roi.edgesChanged.connect(_update_coords)
else:
roi.centerChanged.connect(_update_coords)
# sync width edits back to spinbox
roi.penChanged.connect(lambda r=roi, sp=width_spin: sp.setValue(r.line_width))
roi.nameChanged.connect(lambda n, itm=parent: itm.setText(self.COL_ROI, n))
# color changes
roi.penChanged.connect(lambda r=roi, b=color_btn: b.set_color(r.line_color))
for c in range(3):
self.tree.resizeColumnToContents(c)
def _toggle_movable(self, roi: BaseROI):
"""
Toggle the `movable` property of the given ROI.
"""
roi.movable = not roi.movable
def _on_roi_removed(self, roi: BaseROI):
item = self.roi_items.pop(roi, None)
if item:
idx = self.tree.indexOfTopLevelItem(item)
self.tree.takeTopLevelItem(idx)
# ---------------------------------------------------------- event handlers
def _pick_color(self, roi: BaseROI, btn: "ColorButtonNative"):
clr = QColorDialog.getColor(QColor(roi.line_color), self, "Select ROI Color")
if clr.isValid():
roi.line_color = clr.name()
btn.set_color(clr)
def _on_item_edited(self, item: QTreeWidgetItem, col: int):
if col != self.COL_ROI:
return
# find which roi
for r, it in self.roi_items.items():
if it is item:
r.label = item.text(self.COL_ROI)
break
def _delete_roi(self, roi):
self.controller.remove_roi(roi)
def cleanup(self):
self.cmap.close()
self.cmap.deleteLater()
super().cleanup()
# Demo
if __name__ == "__main__": # pragma: no cover
import sys
import numpy as np
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.plots.image.image import Image
app = QApplication(sys.argv)
bec_dispatcher = BECDispatcher(gui_id="roi_tree_demo")
client = bec_dispatcher.client
client.start()
image_widget = Image(popups=False)
image_widget.main_image.set_data(np.random.normal(size=(200, 200)))
win = QWidget()
win.setWindowTitle("Modular ROI Demo")
ml = QHBoxLayout(win)
# Add the image widget on the left
ml.addWidget(image_widget)
# ROI manager linked to that image
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
mgr.setFixedWidth(350)
ml.addWidget(mgr)
win.resize(1500, 600)
win.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,59 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
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
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 MonitorSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that controls monitor selection on a plot.
"""
def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# 1) Device combo box
self.device_combo_box = DeviceComboBox(
parent=self.target_widget,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")
self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
# 2) Dimension combo box
self.dim_combo_box = QComboBox(parent=self.target_widget)
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")
self.dim_combo_box.setFixedWidth(60)
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True))
# Connect slots, a device will be connected upon change of any combobox
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
@SafeSlot()
def connect_monitor(self):
dim = self.dim_combo_box.currentText()
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)

View File

@@ -0,0 +1,79 @@
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
class ImageProcessingToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that controls processing of monitor.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
self.fft = MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True)
self.log = MaterialIconAction(icon_name="log_scale", tooltip="Toggle Log", checkable=True)
self.transpose = MaterialIconAction(
icon_name="transform", tooltip="Transpose Image", checkable=True
)
self.right = MaterialIconAction(
icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
)
self.left = MaterialIconAction(
icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
)
self.reset = MaterialIconAction(icon_name="reset_settings", tooltip="Reset Image Settings")
self.add_action("fft", self.fft)
self.add_action("log", self.log)
self.add_action("transpose", self.transpose)
self.add_action("rotate_right", self.right)
self.add_action("rotate_left", self.left)
self.add_action("reset", self.reset)
self.fft.action.triggered.connect(self.toggle_fft)
self.log.action.triggered.connect(self.toggle_log)
self.transpose.action.triggered.connect(self.toggle_transpose)
self.right.action.triggered.connect(self.rotate_right)
self.left.action.triggered.connect(self.rotate_left)
self.reset.action.triggered.connect(self.reset_settings)
@SafeSlot()
def toggle_fft(self):
checked = self.fft.action.isChecked()
self.target_widget.fft = checked
@SafeSlot()
def toggle_log(self):
checked = self.log.action.isChecked()
self.target_widget.log = checked
@SafeSlot()
def toggle_transpose(self):
checked = self.transpose.action.isChecked()
self.target_widget.transpose = checked
@SafeSlot()
def rotate_right(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 - 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 + 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.num_rotation_90 = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)
self.transpose.action.setChecked(False)

View File

@@ -1,390 +0,0 @@
from __future__ import annotations
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
def image_roi_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for ROI and crosshair interaction.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The ROI toolbar bundle.
"""
components.add_safe(
"image_crosshair",
MaterialIconAction(
icon_name="point_scan",
tooltip="Show Crosshair",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_crosshair_roi",
MaterialIconAction(
icon_name="my_location",
tooltip="Show Crosshair with ROI plots",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_switch_crosshair",
SwitchableToolBarAction(
actions={
"crosshair": components.get_action_reference("image_crosshair")(),
"crosshair_roi": components.get_action_reference("image_crosshair_roi")(),
},
initial_action="crosshair",
tooltip="Crosshair",
checkable=True,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("image_crosshair", components)
bundle.add_action("image_switch_crosshair")
return bundle
class ImageRoiConnection(BundleConnection):
"""
Connection class for the ROI toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "roi"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "toggle_roi_panels") or not hasattr(
self.target_widget, "toggle_crosshair"
):
raise AttributeError(
"Target widget must implement 'toggle_roi_panels' and 'toggle_crosshair'."
)
super().__init__()
self._connected = False
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("image_crosshair").action.toggled.connect(
self.target_widget.toggle_crosshair
)
self.components.get_action("image_crosshair_roi").action.triggered.connect(
self.target_widget.toggle_roi_panels
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("image_crosshair").action.toggled.disconnect(
self.target_widget.toggle_crosshair
)
self.components.get_action("image_crosshair_roi").action.triggered.disconnect(
self.target_widget.toggle_roi_panels
)
def image_autorange(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for image autorange functionality.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The autorange toolbar bundle.
"""
components.add_safe(
"image_autorange_mean",
MaterialIconAction(
icon_name="hdr_auto",
tooltip="Enable Auto Range (Mean)",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_autorange_max",
MaterialIconAction(
icon_name="hdr_auto",
tooltip="Enable Auto Range (Max)",
checkable=True,
parent=components.toolbar,
filled=True,
),
)
components.add_safe(
"image_autorange",
SwitchableToolBarAction(
actions={
"mean": components.get_action_reference("image_autorange_mean")(),
"max": components.get_action_reference("image_autorange_max")(),
},
initial_action="mean",
tooltip="Autorange",
checkable=True,
parent=components.toolbar,
default_state_checked=True,
),
)
bundle = ToolbarBundle("image_autorange", components)
bundle.add_action("image_autorange")
return bundle
def image_colorbar(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for image colorbar functionality.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The colorbar toolbar bundle.
"""
components.add_safe(
"image_full_colorbar",
MaterialIconAction(
icon_name="edgesensor_low",
tooltip="Enable Full Colorbar",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_simple_colorbar",
MaterialIconAction(
icon_name="smartphone",
tooltip="Enable Simple Colorbar",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_colorbar_switch",
SwitchableToolBarAction(
actions={
"full_colorbar": components.get_action_reference("image_full_colorbar")(),
"simple_colorbar": components.get_action_reference("image_simple_colorbar")(),
},
initial_action="full_colorbar",
tooltip="Colorbar",
checkable=True,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("image_colorbar", components)
bundle.add_action("image_colorbar_switch")
return bundle
class ImageColorbarConnection(BundleConnection):
"""
Connection class for the image colorbar toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "image_colorbar"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "enable_colorbar"):
raise AttributeError("Target widget must implement 'enable_colorbar' method.")
super().__init__()
self._connected = False
def _enable_full_colorbar(self, checked: bool):
"""
Enable or disable the full colorbar based on the checked state.
"""
self.target_widget.enable_colorbar(checked, style="full")
def _enable_simple_colorbar(self, checked: bool):
"""
Enable or disable the simple colorbar based on the checked state.
"""
self.target_widget.enable_colorbar(checked, style="simple")
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("image_full_colorbar").action.toggled.connect(
self._enable_full_colorbar
)
self.components.get_action("image_simple_colorbar").action.toggled.connect(
self._enable_simple_colorbar
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("image_full_colorbar").action.toggled.disconnect(
self._enable_full_colorbar
)
self.components.get_action("image_simple_colorbar").action.toggled.disconnect(
self._enable_simple_colorbar
)
def image_processing(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a toolbar bundle for image processing functionality.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The image processing toolbar bundle.
"""
components.add_safe(
"image_processing_fft",
MaterialIconAction(
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=components.toolbar
),
)
components.add_safe(
"image_processing_log",
MaterialIconAction(
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=components.toolbar
),
)
components.add_safe(
"image_processing_transpose",
MaterialIconAction(
icon_name="transform",
tooltip="Transpose Image",
checkable=True,
parent=components.toolbar,
),
)
components.add_safe(
"image_processing_rotate_right",
MaterialIconAction(
icon_name="rotate_right",
tooltip="Rotate image clockwise by 90 deg",
parent=components.toolbar,
),
)
components.add_safe(
"image_processing_rotate_left",
MaterialIconAction(
icon_name="rotate_left",
tooltip="Rotate image counterclockwise by 90 deg",
parent=components.toolbar,
),
)
components.add_safe(
"image_processing_reset",
MaterialIconAction(
icon_name="reset_settings", tooltip="Reset Image Settings", parent=components.toolbar
),
)
bundle = ToolbarBundle("image_processing", components)
bundle.add_action("image_processing_fft")
bundle.add_action("image_processing_log")
bundle.add_action("image_processing_transpose")
bundle.add_action("image_processing_rotate_right")
bundle.add_action("image_processing_rotate_left")
bundle.add_action("image_processing_reset")
return bundle
class ImageProcessingConnection(BundleConnection):
"""
Connection class for the image processing toolbar bundle.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "image_processing"
self.components = components
self.target_widget = target_widget
if (
not hasattr(self.target_widget, "fft")
or not hasattr(self.target_widget, "log")
or not hasattr(self.target_widget, "transpose")
or not hasattr(self.target_widget, "num_rotation_90")
):
raise AttributeError(
"Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes."
)
super().__init__()
self.fft = components.get_action("image_processing_fft")
self.log = components.get_action("image_processing_log")
self.transpose = components.get_action("image_processing_transpose")
self.right = components.get_action("image_processing_rotate_right")
self.left = components.get_action("image_processing_rotate_left")
self.reset = components.get_action("image_processing_reset")
self._connected = False
@SafeSlot()
def toggle_fft(self):
checked = self.fft.action.isChecked()
self.target_widget.fft = checked
@SafeSlot()
def toggle_log(self):
checked = self.log.action.isChecked()
self.target_widget.log = checked
@SafeSlot()
def toggle_transpose(self):
checked = self.transpose.action.isChecked()
self.target_widget.transpose = checked
@SafeSlot()
def rotate_right(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 - 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.num_rotation_90 is None:
return
rotation = (self.target_widget.num_rotation_90 + 1) % 4
self.target_widget.num_rotation_90 = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.num_rotation_90 = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)
self.transpose.action.setChecked(False)
def connect(self):
"""
Connect the actions to the target widget's methods.
"""
self._connected = True
self.fft.action.triggered.connect(self.toggle_fft)
self.log.action.triggered.connect(self.toggle_log)
self.transpose.action.triggered.connect(self.toggle_transpose)
self.right.action.triggered.connect(self.rotate_right)
self.left.action.triggered.connect(self.rotate_left)
self.reset.action.triggered.connect(self.reset_settings)
def disconnect(self):
"""
Disconnect the actions from the target widget's methods.
"""
if not self._connected:
return
self.fft.action.triggered.disconnect(self.toggle_fft)
self.log.action.triggered.disconnect(self.toggle_log)
self.transpose.action.triggered.disconnect(self.toggle_transpose)
self.right.action.triggered.disconnect(self.rotate_right)
self.left.action.triggered.disconnect(self.rotate_left)
self.reset.action.triggered.disconnect(self.reset_settings)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
@@ -14,12 +15,12 @@ from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.utils.toolbar import MaterialIconAction
from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings
from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import (
MotorSelectionAction,
from bec_widgets.widgets.plots.motor_map.toolbar_bundles.motor_selection import (
MotorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from bec_widgets.widgets.plots.plot_base import PlotBase
logger = bec_logger.logger
@@ -181,60 +182,33 @@ class MotorMap(PlotBase):
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
self._init_motor_map_toolbar()
self._add_motor_map_settings()
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_motor_map_toolbar(self):
def _init_toolbar(self):
"""
Initialize the toolbar for the motor map widget.
"""
motor_selection = MotorSelectionAction(parent=self)
self.toolbar.add_action("motor_selection", motor_selection)
motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed)
motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed)
self.toolbar.components.get_action("reset_legend").action.setVisible(False)
reset_legend = MaterialIconAction(
icon_name="history",
tooltip="Reset the position of legend.",
checkable=False,
parent=self,
self.motor_selection_bundle = MotorSelectionToolbarBundle(
bundle_id="motor_selection", target_widget=self
)
self.toolbar.components.add_safe("reset_motor_map_legend", reset_legend)
self.toolbar.get_bundle("roi").add_action("reset_motor_map_legend")
reset_legend.action.triggered.connect(self.reset_history)
self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self)
super()._init_toolbar()
self.toolbar.widgets["reset_legend"].action.setVisible(False)
settings_brightness = MaterialIconAction(
icon_name="settings_brightness",
tooltip="Show Motor Map Settings",
checkable=True,
parent=self,
self.reset_legend_action = MaterialIconAction(
icon_name="history", tooltip="Reset the position of legend."
)
self.toolbar.components.add_safe("motor_map_settings", settings_brightness)
self.toolbar.get_bundle("axis_popup").add_action("motor_map_settings")
settings_brightness.action.triggered.connect(self.show_motor_map_settings)
bundles = ["motor_selection", "plot_export", "mouse_interaction", "roi"]
if self.ui_mode == UIMode.POPUP:
bundles.append("axis_popup")
self.toolbar.show_bundles(bundles)
@SafeSlot()
def on_motor_selection_changed(self, _):
action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection")
motor_x = action.motor_x.currentText()
motor_y = action.motor_y.currentText()
if motor_x != "" and motor_y != "":
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
self.map(motor_x, motor_y)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="motor_map_history",
action=self.reset_legend_action,
target_widget=self,
)
self.reset_legend_action.action.triggered.connect(self.reset_history)
def _add_motor_map_settings(self):
"""Add the motor map settings to the side panel."""
@@ -247,11 +221,32 @@ class MotorMap(PlotBase):
title="Motor Map Settings",
)
def add_popups(self):
"""
Add popups to the ScatterWaveform widget.
"""
super().add_popups()
scatter_curve_setting_action = MaterialIconAction(
icon_name="settings_brightness",
tooltip="Show Motor Map Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="motor_map_settings",
action=scatter_curve_setting_action,
target_widget=self,
)
self.toolbar.widgets["motor_map_settings"].action.triggered.connect(
self.show_motor_map_settings
)
def show_motor_map_settings(self):
"""
Show the DAP summary popup.
"""
action = self.toolbar.components.get_action("motor_map_settings").action
action = self.toolbar.widgets["motor_map_settings"].action
if self.motor_map_settings is None or not self.motor_map_settings.isVisible():
motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=True)
self.motor_map_settings = SettingsDialog(
@@ -277,7 +272,7 @@ class MotorMap(PlotBase):
"""
self.motor_map_settings.deleteLater()
self.motor_map_settings = None
self.toolbar.components.get_action("motor_map_settings").action.setChecked(False)
self.toolbar.widgets["motor_map_settings"].action.setChecked(False)
################################################################################
# Widget Specific Properties
@@ -771,21 +766,20 @@ class MotorMap(PlotBase):
"""
Sync the motor map selection toolbar with the current motor map.
"""
motor_selection = self.toolbar.components.get_action("motor_selection")
if self.motor_selection_bundle is not None:
motor_x = self.motor_selection_bundle.motor_x.currentText()
motor_y = self.motor_selection_bundle.motor_y.currentText()
motor_x = motor_selection.motor_x.currentText()
motor_y = motor_selection.motor_y.currentText()
if motor_x != self.config.x_motor.name:
motor_selection.motor_x.blockSignals(True)
motor_selection.motor_x.set_device(self.config.x_motor.name)
motor_selection.motor_x.check_validity(self.config.x_motor.name)
motor_selection.motor_x.blockSignals(False)
if motor_y != self.config.y_motor.name:
motor_selection.motor_y.blockSignals(True)
motor_selection.motor_y.set_device(self.config.y_motor.name)
motor_selection.motor_y.check_validity(self.config.y_motor.name)
motor_selection.motor_y.blockSignals(False)
if motor_x != self.config.x_motor.name:
self.motor_selection_bundle.motor_x.blockSignals(True)
self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name)
self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name)
self.motor_selection_bundle.motor_x.blockSignals(False)
if motor_y != self.config.y_motor.name:
self.motor_selection_bundle.motor_y.blockSignals(True)
self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name)
self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name)
self.motor_selection_bundle.motor_y.blockSignals(False)
################################################################################
# Export Methods
@@ -801,6 +795,10 @@ class MotorMap(PlotBase):
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
return data
def cleanup(self):
self.motor_selection_bundle.cleanup()
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@@ -0,0 +1,70 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
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
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 MotorSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that selects motors.
"""
def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Motor X
self.motor_x = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
# Motor X
self.motor_y = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False))
self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.motor_x.currentTextChanged.connect(lambda: self.connect_motors())
self.motor_y.currentTextChanged.connect(lambda: self.connect_motors())
@SafeSlot()
def connect_motors(self):
motor_x = self.motor_x.currentText()
motor_y = self.motor_y.currentText()
if motor_x != "" and motor_y != "":
if (
motor_x != self.target_widget.config.x_motor.name
or motor_y != self.target_widget.config.y_motor.name
):
self.target_widget.map(motor_x, motor_y)
def cleanup(self):
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()

View File

@@ -1,51 +0,0 @@
from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction
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
class MotorSelectionAction(ToolBarAction):
def __init__(self, parent=None):
super().__init__(icon_path=None, tooltip=None, checkable=False)
self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
self.container = QWidget(parent)
layout = QHBoxLayout(self.container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.motor_x)
layout.addWidget(self.motor_y)
self.container.setLayout(layout)
self.action = self.container
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.
"""
toolbar.addWidget(self.container)
def cleanup(self):
"""
Cleans up the action, if necessary.
"""
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()
self.container.close()
self.container.deleteLater()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING, cast
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
@@ -13,15 +12,13 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.plots.multi_waveform.settings.control_panel import (
MultiWaveformControlPanel,
)
from bec_widgets.widgets.plots.multi_waveform.toolbar_components.monitor_selection import (
monitor_selection_bundle,
from bec_widgets.widgets.plots.multi_waveform.toolbar_bundles.monitor_selection import (
MultiWaveformSelectionToolbarBundle,
)
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
logger = bec_logger.logger
@@ -94,8 +91,6 @@ class MultiWaveform(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# MultiWaveform Specific RPC Access
"highlighted_index",
"highlighted_index.setter",
@@ -144,54 +139,33 @@ class MultiWaveform(PlotBase):
self.visible_curves = []
self.number_of_visible_curves = 0
self._init_multiwaveform_toolbar()
self._init_control_panel()
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_multiwaveform_toolbar(self):
self.toolbar.add_bundle(
monitor_selection_bundle(self.toolbar.components, target_widget=self)
def _init_toolbar(self):
self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle(
bundle_id="motor_selection", target_widget=self
)
self.toolbar.toggle_action_visibility("reset_legend", visible=False)
combobox = self.toolbar.components.get_action("monitor_selection").widget
combobox.currentTextChanged.connect(self.connect_monitor)
cmap = self.toolbar.components.get_action("color_map").widget
cmap.colormap_changed_signal.connect(self.change_colormap)
bundles = self.toolbar.shown_bundles
bundles.insert(0, "monitor_selection")
self.toolbar.show_bundles(bundles)
self._init_control_panel()
self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self)
super()._init_toolbar()
self.toolbar.widgets["reset_legend"].action.setVisible(False)
def _init_control_panel(self):
control_panel = SidePanel(self, orientation="top", panel_max_width=90)
self.layout_manager.add_widget_relative(control_panel, self.round_plot_widget, "bottom")
self.control_panel = SidePanel(self, orientation="top", panel_max_width=90)
self.layout_manager.add_widget_relative(
self.control_panel, self.round_plot_widget, "bottom"
)
self.controls = MultiWaveformControlPanel(parent=self, target_widget=self)
control_panel.add_menu(
self.control_panel.add_menu(
action_id="control",
icon_name="tune",
tooltip="Show Control panel",
widget=self.controls,
title=None,
)
control_panel.toolbar.components.get_action("control").action.trigger()
@SafeSlot()
def connect_monitor(self, _):
combobox = self.toolbar.components.get_action("monitor_selection").widget
monitor = combobox.currentText()
if monitor != "":
if monitor != self.config.monitor:
self.config.monitor = monitor
@SafeSlot(str)
def change_colormap(self, colormap: str):
self.color_palette = colormap
self.control_panel.toolbar.widgets["control"].action.trigger()
################################################################################
# Widget Specific Properties
@@ -512,30 +486,23 @@ class MultiWaveform(PlotBase):
"""
Sync the motor map selection toolbar with the current motor map.
"""
if self.monitor_selection_bundle is not None:
monitor = self.monitor_selection_bundle.monitor.currentText()
color_palette = self.monitor_selection_bundle.colormap_widget.colormap
combobox_widget: DeviceComboBox = cast(
DeviceComboBox, self.toolbar.components.get_action("monitor_selection").widget
)
cmap_widget: BECColorMapWidget = cast(
BECColorMapWidget, self.toolbar.components.get_action("color_map").widget
)
if monitor != self.config.monitor:
self.monitor_selection_bundle.monitor.blockSignals(True)
self.monitor_selection_bundle.monitor.set_device(self.config.monitor)
self.monitor_selection_bundle.monitor.check_validity(self.config.monitor)
self.monitor_selection_bundle.monitor.blockSignals(False)
monitor = combobox_widget.currentText()
color_palette = cmap_widget.colormap
if monitor != self.config.monitor:
combobox_widget.setCurrentText(monitor)
combobox_widget.blockSignals(True)
combobox_widget.set_device(self.config.monitor)
combobox_widget.check_validity(self.config.monitor)
combobox_widget.blockSignals(False)
if color_palette != self.config.color_palette:
cmap_widget.blockSignals(True)
cmap_widget.colormap = self.config.color_palette
cmap_widget.blockSignals(False)
if color_palette != self.config.color_palette:
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
def cleanup(self):
self._disconnect_monitor()
self.clear_curves()
self.monitor_selection_bundle.cleanup()
super().cleanup()

View File

@@ -1,11 +1,9 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QStyledItemDelegate, QWidget
from qtpy.QtWidgets import QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
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
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
@@ -20,37 +18,6 @@ class NoCheckDelegate(QStyledItemDelegate):
option.checkState = Qt.Unchecked
def monitor_selection_bundle(
components: ToolbarComponents, target_widget: QWidget
) -> ToolbarBundle:
"""
Creates a monitor selection toolbar bundle.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The monitor selection toolbar bundle.
"""
components.add_safe(
"monitor_selection",
DeviceComboBoxAction(
target_widget=target_widget,
device_filter=[BECDeviceFilter.DEVICE],
readout_priority_filter=ReadoutPriority.ASYNC,
add_empty_item=True,
no_check_delegate=True,
),
)
components.add_safe(
"color_map", WidgetAction(widget=BECColorMapWidget(cmap="plasma"), adjust_size=False)
)
bundle = ToolbarBundle("monitor_selection", components)
bundle.add_action("monitor_selection")
bundle.add_action("color_map")
return bundle
class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that selects motors.

View File

@@ -14,25 +14,17 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar, ToolbarBundle
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
from bec_widgets.widgets.plots.toolbar_components.axis_settings_popup import (
AxisSettingsPopupConnection,
axis_popup_bundle,
from bec_widgets.widgets.plots.toolbar_bundles.mouse_interactions import (
MouseInteractionToolbarBundle,
)
from bec_widgets.widgets.plots.toolbar_components.mouse_interactions import (
MouseInteractionConnection,
mouse_interaction_bundle,
)
from bec_widgets.widgets.plots.toolbar_components.plot_export import (
PlotExportConnection,
plot_export_bundle,
)
from bec_widgets.widgets.plots.toolbar_components.roi import RoiConnection, roi_bundle
from bec_widgets.widgets.plots.toolbar_bundles.plot_export import PlotExportBundle
from bec_widgets.widgets.plots.toolbar_bundles.roi_bundle import ROIBundle
logger = bec_logger.logger
@@ -110,6 +102,8 @@ class PlotBase(BECWidget, QWidget):
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
self._init_toolbar()
# PlotItem Addons
self.plot_item.addLegend()
@@ -118,19 +112,13 @@ class PlotBase(BECWidget, QWidget):
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
self._user_x_label = ""
self._x_label_suffix = ""
self._x_axis_units = ""
self._user_y_label = ""
self._y_label_suffix = ""
self._y_axis_units = ""
self._minimal_crosshair_precision = 3
# Plot Indicator Items
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self._init_toolbar()
self._init_ui()
self._connect_to_theme_change()
@@ -155,33 +143,36 @@ class PlotBase(BECWidget, QWidget):
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
def _init_toolbar(self):
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
self.toolbar.add_bundle(mouse_interaction_bundle(self.toolbar.components))
self.toolbar.add_bundle(roi_bundle(self.toolbar.components))
self.toolbar.add_bundle(axis_popup_bundle(self.toolbar.components))
self.popup_bundle = None
self.performance_bundle = ToolbarBundle("performance")
self.plot_export_bundle = PlotExportBundle("plot_export", target_widget=self)
self.mouse_bundle = MouseInteractionToolbarBundle("mouse_interaction", target_widget=self)
# self.state_export_bundle = SaveStateBundle("state_export", target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.roi_bundle = ROIBundle("roi", target_widget=self)
self.toolbar.connect_bundle(
"plot_base", PlotExportConnection(self.toolbar.components, self)
# Add elements to toolbar
self.toolbar.add_bundle(self.plot_export_bundle, target_widget=self)
# self.toolbar.add_bundle(self.state_export_bundle, target_widget=self) #TODO ATM disabled, cannot be used in DockArea, which is exposed to the user
self.toolbar.add_bundle(self.mouse_bundle, target_widget=self)
self.toolbar.add_bundle(self.roi_bundle, target_widget=self)
self.performance_bundle.add_action(
"fps_monitor",
MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=self
),
)
self.toolbar.connect_bundle(
"plot_base", PerformanceConnection(self.toolbar.components, self)
)
self.toolbar.connect_bundle(
"plot_base", MouseInteractionConnection(self.toolbar.components, self)
)
self.toolbar.connect_bundle("plot_base", RoiConnection(self.toolbar.components, self))
self.toolbar.connect_bundle(
"plot_base", AxisSettingsPopupConnection(self.toolbar.components, self)
self.toolbar.add_bundle(self.performance_bundle, target_widget=self)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(
lambda checked: setattr(self, "enable_fps_monitor", checked)
)
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
# Get default viewbox state
self.toolbar.show_bundles(
["plot_export", "mouse_interaction", "roi", "performance", "axis_popup"]
)
self.mouse_bundle.get_viewbox_mode()
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
@@ -198,6 +189,45 @@ class PlotBase(BECWidget, QWidget):
except ValueError:
return
def add_popups(self):
"""
Add popups to the toolbar.
"""
self.popup_bundle = ToolbarBundle("popup_bundle")
settings = MaterialIconAction(
icon_name="settings", tooltip="Show Axis Settings", checkable=True, parent=self
)
self.popup_bundle.add_action("axis", settings)
self.toolbar.add_bundle(self.popup_bundle, target_widget=self)
self.toolbar.widgets["axis"].action.triggered.connect(self.show_axis_settings_popup)
def show_axis_settings_popup(self):
"""
Show the axis settings dialog.
"""
settings_action = self.toolbar.widgets["axis"].action
if self.axis_settings_dialog is None or not self.axis_settings_dialog.isVisible():
axis_setting = AxisSettings(parent=self, target_widget=self, popup=True)
self.axis_settings_dialog = SettingsDialog(
self, settings_widget=axis_setting, window_title="Axis Settings", modal=False
)
# When the dialog is closed, update the toolbar icon and clear the reference
self.axis_settings_dialog.finished.connect(self._axis_settings_closed)
self.axis_settings_dialog.show()
settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.axis_settings_dialog.raise_()
self.axis_settings_dialog.activateWindow()
settings_action.setChecked(True) # keep it toggled
def _axis_settings_closed(self):
"""
Slot for when the axis settings dialog is closed.
"""
self.axis_settings_dialog = None
self.toolbar.widgets["axis"].action.setChecked(False)
def reset_legend(self):
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
self.plot_item.legend.autoAnchor(50)
@@ -224,23 +254,22 @@ class PlotBase(BECWidget, QWidget):
raise ValueError("ui_mode must be an instance of UIMode")
self._ui_mode = mode
# First, clear both UI elements:
if self.popup_bundle is not None:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.hide()
# Now, apply the new mode:
if mode == UIMode.POPUP:
shown_bundles = self.toolbar.shown_bundles
if "axis_popup" not in shown_bundles:
shown_bundles.append("axis_popup")
self.toolbar.show_bundles(shown_bundles)
self.side_panel.hide()
if self.popup_bundle is None:
self.add_popups()
else:
for action_id in self.toolbar.bundles["popup_bundle"]:
self.toolbar.widgets[action_id].action.setVisible(True)
elif mode == UIMode.SIDE:
shown_bundles = self.toolbar.shown_bundles
if "axis_popup" in shown_bundles:
shown_bundles.remove("axis_popup")
self.toolbar.show_bundles(shown_bundles)
pb_connection = self.toolbar.bundles["axis_popup"].get_connection("plot_base")
if pb_connection.axis_settings_dialog is not None:
pb_connection.axis_settings_dialog.close()
pb_connection.axis_settings_dialog = None
self.add_side_menus()
self.side_panel.show()
@@ -444,31 +473,12 @@ class PlotBase(BECWidget, QWidget):
self._x_label_suffix = suffix
self._apply_x_label()
@property
def x_label_units(self) -> str:
"""
The units of the x-axis.
"""
return self._x_axis_units
@x_label_units.setter
def x_label_units(self, units: str):
"""
The units of the x-axis.
Args:
units(str): The units to set.
"""
self._x_axis_units = units
self._apply_x_label()
@property
def x_label_combined(self) -> str:
"""
The final label shown on the axis = user portion + suffix + [units].
The final label shown on the axis = user portion + suffix.
"""
units = f" [{self._x_axis_units}]" if self._x_axis_units else ""
return self._user_x_label + self._x_label_suffix + units
return self._user_x_label + self._x_label_suffix
def _apply_x_label(self):
"""
@@ -511,31 +521,12 @@ class PlotBase(BECWidget, QWidget):
self._y_label_suffix = suffix
self._apply_y_label()
@property
def y_label_units(self) -> str:
"""
The units of the y-axis.
"""
return self._y_axis_units
@y_label_units.setter
def y_label_units(self, units: str):
"""
The units of the y-axis.
Args:
units(str): The units to set.
"""
self._y_axis_units = units
self._apply_y_label()
@property
def y_label_combined(self) -> str:
"""
The final y label shown on the axis = user portion + suffix + [units].
The final y label shown on the axis = user portion + suffix.
"""
units = f" [{self._y_axis_units}]" if self._y_axis_units else ""
return self._user_y_label + self._y_label_suffix + units
return self._user_y_label + self._y_label_suffix
def _apply_y_label(self):
"""
@@ -947,9 +938,7 @@ class PlotBase(BECWidget, QWidget):
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(
self.plot_item, min_precision=self._minimal_crosshair_precision
)
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
@@ -977,29 +966,6 @@ class PlotBase(BECWidget, QWidget):
self.unhook_crosshair()
@SafeProperty(
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
)
def minimal_crosshair_precision(self) -> int:
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
return self._minimal_crosshair_precision
@minimal_crosshair_precision.setter
def minimal_crosshair_precision(self, value: int):
"""
Set the minimum decimal places for crosshair when dynamic precision is enabled.
Args:
value(int): The minimum decimal places to set.
"""
value_int = max(0, int(value))
self._minimal_crosshair_precision = value_int
if self.crosshair is not None:
self.crosshair.min_precision = value_int
self.property_changed.emit("minimal_crosshair_precision", value_int)
@SafeSlot()
def reset(self) -> None:
"""Reset the plot widget."""
@@ -1017,7 +983,6 @@ class PlotBase(BECWidget, QWidget):
self.axis_settings_dialog = None
self.cleanup_pyqtgraph()
self.round_plot_widget.close()
self.toolbar.cleanup()
super().cleanup()
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
@@ -1056,12 +1021,8 @@ if __name__ == "__main__": # pragma: no cover:
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
app = QApplication(sys.argv)
launch_window = BECMainWindow()
pb = PlotBase(popups=False)
launch_window.setCentralWidget(pb)
launch_window.show()
window = PlotBase()
window.show()
sys.exit(app.exec_())

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.utils.toolbar import MaterialIconAction
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
ScatterCurve,
@@ -82,8 +82,6 @@ class ScatterWaveform(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# Scatter Waveform Specific RPC Access
"main_curve",
"color_map",
@@ -131,8 +129,8 @@ class ScatterWaveform(PlotBase):
self.proxy_update_sync = pg.SignalProxy(
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
)
self._init_scatter_curve_settings()
if self.ui_mode == UIMode.SIDE:
self._init_scatter_curve_settings()
self.update_with_scan_history(-1)
################################################################################
@@ -143,40 +141,44 @@ class ScatterWaveform(PlotBase):
"""
Initialize the scatter curve settings menu.
"""
if self.ui_mode == UIMode.SIDE:
self.scatter_curve_settings = ScatterCurveSettings(
parent=self, target_widget=self, popup=False
)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=self.scatter_curve_settings,
title="Scatter Curve Settings",
)
else:
scatter_curve_action = MaterialIconAction(
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
checkable=True,
parent=self,
)
self.toolbar.components.add_safe("scatter_waveform_settings", scatter_curve_action)
self.toolbar.get_bundle("axis_popup").add_action("scatter_waveform_settings")
scatter_curve_action.action.triggered.connect(self.show_scatter_curve_settings)
shown_bundles = self.toolbar.shown_bundles
if "performance" in shown_bundles:
shown_bundles.remove("performance")
self.toolbar.show_bundles(shown_bundles)
self.scatter_curve_settings = ScatterCurveSettings(
parent=self, target_widget=self, popup=False
)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=self.scatter_curve_settings,
title="Scatter Curve Settings",
)
def add_popups(self):
"""
Add popups to the ScatterWaveform widget.
"""
super().add_popups()
scatter_curve_setting_action = MaterialIconAction(
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="scatter_waveform_settings",
action=scatter_curve_setting_action,
target_widget=self,
)
self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect(
self.show_scatter_curve_settings
)
def show_scatter_curve_settings(self):
"""
Show the scatter curve settings dialog.
"""
scatter_settings_action = self.toolbar.components.get_action(
"scatter_waveform_settings"
).action
scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action
if self.scatter_dialog is None or not self.scatter_dialog.isVisible():
scatter_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=True)
self.scatter_dialog = SettingsDialog(
@@ -201,7 +203,7 @@ class ScatterWaveform(PlotBase):
Slot for when the scatter curve settings dialog is closed.
"""
self.scatter_dialog = None
self.toolbar.components.get_action("scatter_waveform_settings").action.setChecked(False)
self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False)
################################################################################
# Widget Specific Properties

View File

@@ -141,14 +141,6 @@
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_name</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_name</tabstop>
<tabstop>z_entry</tabstop>
</tabstops>
<resources/>
<connections>
<connection>

View File

@@ -60,7 +60,6 @@ class AxisSettings(SettingWidget):
self.ui.y_grid,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.minimal_crosshair_precision,
]:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
@@ -122,7 +121,6 @@ class AxisSettings(SettingWidget):
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
self.ui.minimal_crosshair_precision,
]:
property_name = widget.objectName()
value = getattr(self.target_widget, property_name)
@@ -146,7 +144,6 @@ class AxisSettings(SettingWidget):
self.ui.y_grid,
self.ui.outer_axes,
self.ui.inner_axes,
self.ui.minimal_crosshair_precision,
]:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)

View File

@@ -14,6 +14,97 @@
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="2" column="2" colspan="2">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
@@ -88,87 +179,6 @@
</layout>
</widget>
</item>
<item row="1" column="3">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="5" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="ToggleSwitch" name="x_log">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="ToggleSwitch" name="x_grid">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Log</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="1" column="2">
<widget class="BECSpinBox" name="x_min"/>
</item>
<item row="2" column="2">
<widget class="BECSpinBox" name="x_max"/>
</item>
</layout>
</widget>
</item>
<item row="0" column="0" colspan="4">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
@@ -181,41 +191,8 @@
<item>
<widget class="QLineEdit" name="title"/>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="minimal_crosshair_precision">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="maximum">
<number>20</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout>
</widget>
<customwidgets>

View File

@@ -6,84 +6,15 @@
<rect>
<x>0</x>
<y>0</y>
<width>250</width>
<height>612</height>
<width>241</width>
<height>526</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="general_box">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="4" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="title"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="minimal_crosshair_precision">
<property name="toolTip">
<string>Minimal Crosshair Precision</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="maximum">
<number>20</number>
</property>
<property name="value">
<number>3</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="4" column="0" colspan="2">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
@@ -150,7 +81,28 @@
</layout>
</widget>
</item>
<item>
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="title"/>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="5" column="0" colspan="2">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
@@ -217,6 +169,23 @@
</layout>
</widget>
</item>
<item row="2" column="1">
<widget class="ToggleSwitch" name="outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Inner Axes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="inner_axes"/>
</item>
</layout>
</widget>
<customwidgets>

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