mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 10:10:55 +02:00
Compare commits
2 Commits
docs/wavef
...
feature/ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 4947549a20 | |||
| 59e4c2f612 |
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Bug report
|
||||
description: File a bug report.
|
||||
title: "[BUG]: "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Bug report:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Provide a brief description of the bug.
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Describe what you expected to happen and what actually happened.
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: bec_widgets version
|
||||
description: which version of BEC widgets was running?
|
||||
- type: input
|
||||
id: bec-version
|
||||
attributes:
|
||||
label: bec core version
|
||||
description: which version of BEC core was running?
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Any extra info / data? e.g. log output...
|
||||
- type: input
|
||||
id: issues
|
||||
attributes:
|
||||
label: Related issues
|
||||
description: please tag any related issues
|
||||
64
.github/actions/bw_install/action.yml
vendored
64
.github/actions/bw_install/action.yml
vendored
@@ -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]
|
||||
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
@@ -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()
|
||||
@@ -1,2 +0,0 @@
|
||||
pydantic
|
||||
pygithub
|
||||
28
.github/workflows/check_pr.yml
vendored
28
.github/workflows/check_pr.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Check PR status for branch
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
branch-pr:
|
||||
description: The PR number if the branch is in one
|
||||
value: ${{ jobs.pr.outputs.branch-pr }}
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
runs-on: "ubuntu-latest"
|
||||
outputs:
|
||||
branch-pr: ${{ steps.script.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
id: script
|
||||
if: github.event_name == 'push' && github.event.ref_type != 'tag'
|
||||
with:
|
||||
script: |
|
||||
const prs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
head: context.repo.owner + ':${{ github.ref_name }}'
|
||||
})
|
||||
if (prs.data.length) {
|
||||
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
|
||||
return prs.data[0]["number"]
|
||||
}
|
||||
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Full CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check_pr_status:
|
||||
uses: ./.github/workflows/check_pr.yml
|
||||
|
||||
formatter:
|
||||
needs: check_pr_status
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/formatter.yml
|
||||
|
||||
unit-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
unit-test-matrix:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/generate-cli-check.yml
|
||||
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
58
.github/workflows/end2end-conda.yml
vendored
58
.github/workflows/end2end-conda.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Run Pytest with Coverage
|
||||
on: [workflow_call]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Conda
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
auto-update-conda: true
|
||||
auto-activate-base: true
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Conda install and run pytest
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
|
||||
cd ./bec
|
||||
conda create -q -n test-environment python=3.11
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
66
.github/workflows/formatter.yml
vendored
66
.github/workflows/formatter.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: Formatter and Pylint jobs
|
||||
on: [workflow_call]
|
||||
jobs:
|
||||
|
||||
Formatter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Run black and isort
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system black isort
|
||||
uv pip install --system -e .[dev]
|
||||
black --check --diff --color .
|
||||
isort --check --diff ./
|
||||
|
||||
- name: Check for disallowed imports from PySide
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
|
||||
Pylint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pylint pylint-exit anybadge
|
||||
|
||||
- name: Run Pylint
|
||||
run: |
|
||||
mkdir -p ./pylint
|
||||
set +e
|
||||
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
|
||||
pylint-exit $?
|
||||
set -e
|
||||
|
||||
- name: Extract Pylint Score
|
||||
id: score
|
||||
run: |
|
||||
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Badge
|
||||
run: |
|
||||
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pylint-artifacts
|
||||
path: |
|
||||
# ./pylint/pylint.log # not sure why this isn't working
|
||||
./pylint/pylint.svg
|
||||
49
.github/workflows/generate-cli-check.yml
vendored
49
.github/workflows/generate-cli-check.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Run bw-generate-cli
|
||||
on: [workflow_call]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install os dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e ./bec/bec_lib[dev]
|
||||
pip install -e ./bec/bec_ipython_client
|
||||
pip install -e .[dev,pyside6]
|
||||
|
||||
- name: Run bw-generate-cli
|
||||
run: |
|
||||
bw-generate-cli --target bec_widgets
|
||||
git diff --exit-code
|
||||
|
||||
59
.github/workflows/pytest-matrix.yml
vendored
59
.github/workflows/pytest-matrix.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Run Pytest with different Python versions
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
pytest-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
env:
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
64
.github/workflows/pytest.yml
vendored
64
.github/workflows/pytest.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
- name: Run Pytest with Coverage
|
||||
id: coverage
|
||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: bec-project/bec_widgets
|
||||
103
.github/workflows/semantic_release.yml
vendored
103
.github/workflows/semantic_release.yml
vendored
@@ -1,103 +0,0 @@
|
||||
name: Continuous Delivery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# default: least privileged permissions across all jobs
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
# Note: We checkout the repository at the branch that triggered the workflow
|
||||
# with the entire history to ensure to match PSR's release branch detection
|
||||
# and history evaluation.
|
||||
# However, we forcefully reset the branch to the workflow sha because it is
|
||||
# possible that the branch was updated while the workflow was running. This
|
||||
# prevents accidentally releasing un-evaluated changes.
|
||||
- name: Setup | Checkout Repository on Release Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
|
||||
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup | Force release branch to be at workflow sha
|
||||
run: |
|
||||
git reset --hard ${{ github.sha }}
|
||||
- name: Evaluate | Verify upstream has NOT changed
|
||||
# Last chance to abort before causing an error as another PR/push was applied to
|
||||
# the upstream branch while this workflow was running. This is important
|
||||
# because we are committing a version change (--commit). You may omit this step
|
||||
# if you have 'commit: false' in your configuration.
|
||||
#
|
||||
# You may consider moving this to a repo script and call it from this step instead
|
||||
# of writing it in-line.
|
||||
shell: bash
|
||||
run: |
|
||||
set +o pipefail
|
||||
|
||||
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
|
||||
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
|
||||
|
||||
set -o pipefail
|
||||
|
||||
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
|
||||
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
|
||||
|
||||
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
|
||||
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
|
||||
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
|
||||
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
|
||||
|
||||
- name: Semantic Version Release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
pip install python-semantic-release==9.* wheel build twine
|
||||
semantic-release -vv version
|
||||
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
|
||||
semantic-release publish
|
||||
15
.github/workflows/stale-issues.yml
vendored
15
.github/workflows/stale-issues.yml
vendored
@@ -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
|
||||
40
.github/workflows/sync-issues-pr.yml
vendored
40
.github/workflows/sync-issues-pr.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -64,9 +64,6 @@ coverage.xml
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Output from end2end testing
|
||||
tests/reference_failures/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
@@ -13,7 +13,7 @@ variables:
|
||||
value: main
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
CHECK_PKG_VERSIONS:
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
value: 0
|
||||
|
||||
workflow:
|
||||
@@ -35,7 +35,8 @@ include:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v,--random-order,tests/unit_tests"
|
||||
pip_args: ".[dev]"
|
||||
ignore_dep_group: "pyqt6"
|
||||
pip_args: ".[dev,pyside6]"
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
@@ -77,7 +78,7 @@ formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install -e ./[dev]
|
||||
- pip install bec_lib[dev]
|
||||
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
|
||||
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
|
||||
rules:
|
||||
@@ -88,7 +89,7 @@ pylint:
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
- pip install -e .[dev,pyqt6]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
@@ -162,20 +163,6 @@ tests:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
generate-client-check:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- pip install -e .[dev,pyside6]
|
||||
- bw-generate-cli --target bec_widgets
|
||||
# if there are changes in the generated files, fail the job
|
||||
- git diff --exit-code
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
@@ -203,20 +190,14 @@ test-matrix:
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3:25.1.1-2
|
||||
image: continuumio/miniconda3
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- conda config --prepend channels conda-forge
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
@@ -230,7 +211,8 @@ end-2-end-conda:
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
- cd ./tests/end-2-end
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
@@ -245,7 +227,6 @@ end-2-end-conda:
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
|
||||
17
.gitlab/issue_templates/bug_report_template.md
Normal file
17
.gitlab/issue_templates/bug_report_template.md
Normal 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.]
|
||||
@@ -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]
|
||||
@@ -1,13 +1,3 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FEAT]: '
|
||||
type: feature
|
||||
label: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
@@ -47,3 +37,4 @@ assignees: ''
|
||||
## Additional Information
|
||||
|
||||
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
4392
CHANGELOG.md
4392
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, Paul Scherrer Institute
|
||||
Copyright (c) 2023, bec
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
11
README.md
11
README.md
@@ -1,16 +1,5 @@
|
||||
# BEC Widgets
|
||||
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
198
bec_widgets/applications/alignment/alignment_1d/alignment_1d.py
Normal file
198
bec_widgets/applications/alignment/alignment_1d/alignment_1d.py
Normal file
@@ -0,0 +1,198 @@
|
||||
""" This module contains the GUI for the 1D alignment application.
|
||||
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
|
||||
PositionerGroup,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class Alignment1D:
|
||||
"""Alignment GUI to perform 1D scans"""
|
||||
|
||||
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
|
||||
"""Initialization
|
||||
|
||||
Args:
|
||||
config: Configuration of the application.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
"""
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
QApplication.instance().aboutToQuit.connect(self.close)
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.ui_file = "alignment_1d.ui"
|
||||
self.ui = None
|
||||
self.progress_bar = None
|
||||
self.waveform = None
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialise the UI from QT Designer file"""
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
|
||||
# Customize the plotting widget
|
||||
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
|
||||
self._customise_bec_waveform_widget()
|
||||
# Setup comboboxes for motor and signal selection
|
||||
# FIXME after changing the filtering in the combobox
|
||||
self._setup_signal_combobox()
|
||||
# Setup motor indicator
|
||||
self._setup_motor_indicator()
|
||||
# Setup progress bar
|
||||
self._setup_progress_bar()
|
||||
# Add actions buttons
|
||||
self._customise_buttons()
|
||||
# Hook scaninfo updates
|
||||
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
|
||||
|
||||
def show(self):
|
||||
return self.ui.show()
|
||||
|
||||
##############################
|
||||
############ SLOTS ###########
|
||||
##############################
|
||||
|
||||
@Slot(dict, dict)
|
||||
def scan_status_callback(self, content: dict, _) -> None:
|
||||
"""This slot allows to enable/disable the UI critical components when a scan is running"""
|
||||
if content["status"] in ["open"]:
|
||||
self.enable_ui(False)
|
||||
elif content["status"] in ["aborted", "halted", "closed"]:
|
||||
self.enable_ui(True)
|
||||
|
||||
@Slot(tuple)
|
||||
def move_to_center(self, move_request: tuple) -> None:
|
||||
"""Move the selected motor to the center"""
|
||||
motor = self.ui.device_combobox.currentText()
|
||||
if move_request[0] in ["center", "center1", "center2"]:
|
||||
pos = move_request[1]
|
||||
self.dev.get(motor).move(float(pos), relative=False)
|
||||
|
||||
@Slot()
|
||||
def reset_progress_bar(self) -> None:
|
||||
"""Reset the progress bar"""
|
||||
self.progress_bar.set_value(0)
|
||||
self.progress_bar.set_minimum(0)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_progress_bar(self, content: dict, _) -> None:
|
||||
"""Hook to update the progress bar
|
||||
|
||||
Args:
|
||||
content: Content of the scan progress message.
|
||||
metadata: Metadata of the message.
|
||||
"""
|
||||
if content["max_value"] == 0:
|
||||
self.progress_bar.set_value(0)
|
||||
return
|
||||
self.progress_bar.set_maximum(content["max_value"])
|
||||
self.progress_bar.set_value(content["value"])
|
||||
|
||||
@Slot()
|
||||
def clear_queue(self) -> None:
|
||||
"""Clear the scan queue"""
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
##############################
|
||||
######## END OF SLOTS ########
|
||||
##############################
|
||||
|
||||
def enable_ui(self, enable: bool) -> None:
|
||||
"""Enable or disable the UI components"""
|
||||
# Enable/disable motor and signal selection
|
||||
self.ui.device_combobox_2.setEnabled(enable)
|
||||
# Enable/disable DAP selection
|
||||
self.ui.dap_combo_box.setEnabled(enable)
|
||||
# Enable/disable Scan Button
|
||||
# self.ui.scan_button.setEnabled(enable)
|
||||
# Disable move to buttons in LMFitDialog
|
||||
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
|
||||
|
||||
def _customise_buttons(self) -> None:
|
||||
"""Add action buttons for the Action Control.
|
||||
In addition, we are adding a callback to also clear the queue to the stop button
|
||||
to ensure that upon clicking the button, no scans from another client may be queued
|
||||
which would be confusing without the queue widget.
|
||||
"""
|
||||
fit_dialog = self.ui.findChild(LMFitDialog)
|
||||
fit_dialog.active_action_list = ["center", "center1", "center2"]
|
||||
fit_dialog.move_action.connect(self.move_to_center)
|
||||
stop_button = self.ui.findChild(StopButton)
|
||||
stop_button.button.setText("Stop and Clear Queue")
|
||||
stop_button.button.clicked.connect(self.clear_queue)
|
||||
|
||||
def _customise_bec_waveform_widget(self) -> None:
|
||||
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
|
||||
self.waveform.toolbar.clear()
|
||||
|
||||
def _setup_motor_indicator(self) -> None:
|
||||
"""Setup the arrow item"""
|
||||
self.waveform.waveform.tick_item.add_to_plot()
|
||||
positioner_box = self.ui.findChild(PositionerGroup)
|
||||
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
|
||||
self.waveform.waveform.tick_item.set_position(0)
|
||||
|
||||
def _setup_signal_combobox(self) -> None:
|
||||
"""Setup signal selection"""
|
||||
# FIXME after changing the filtering in the combobox
|
||||
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
|
||||
self.ui.device_combobox_2.setCurrentText(signals[0])
|
||||
self.ui.device_combobox_2.set_device_filter("Signal")
|
||||
|
||||
def _setup_progress_bar(self) -> None:
|
||||
"""Setup progress bar"""
|
||||
# FIXME once the BECScanProgressBar is implemented
|
||||
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
|
||||
self.progress_bar.set_value(0)
|
||||
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
|
||||
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
|
||||
|
||||
def close(self):
|
||||
logger.info("Disconnecting", repr(self.bec_dispatcher))
|
||||
self.bec_dispatcher.disconnect_all()
|
||||
logger.info("Shutting down BEC Client", repr(self.client))
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
window = Alignment1D()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
615
bec_widgets/applications/alignment/alignment_1d/alignment_1d.ui
Normal file
615
bec_widgets/applications/alignment/alignment_1d/alignment_1d.ui
Normal file
@@ -0,0 +1,615 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>mainWindow</class>
|
||||
<widget class="QMainWindow" name="mainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1611</width>
|
||||
<height>1019</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Alignment tool</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="widget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||
<item>
|
||||
<widget class="DarkModeButton" name="dark_mode_button"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_6">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="label" stdset="0">
|
||||
<string>BEC Servers</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_5">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>SLS Light On</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioButton_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEAMLINE Checks</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="StopButton" name="stop_button">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="bec_progress_bar">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="ControlTab">
|
||||
<attribute name="title">
|
||||
<string>Alignment Control</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_4" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control">
|
||||
<property name="current_scan" stdset="0">
|
||||
<string>line_scan</string>
|
||||
</property>
|
||||
<property name="hide_arg_box" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="hide_scan_selection_combobox" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_add_remove_buttons" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="PositionerGroup" name="positioner_group"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_3" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>4</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_2" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Monitor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DeviceComboBox" name="device_combobox_2"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>LMFit Model</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DapComboBox" name="dap_combo_box"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Enable ROI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="toggle_switch">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>3</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Activate linear region select for LMFit</string>
|
||||
</property>
|
||||
<property name="layoutDirection">
|
||||
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_8">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECWaveformWidget" name="bec_waveform_widget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>600</width>
|
||||
<height>450</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="clear_curves_on_plot_update" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="LMFitDialog" name="lm_fit_dialog">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>190</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="always_show_latest" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_curve_selection" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_summary" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>Logbook</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="WebsiteWidget" name="website_widget">
|
||||
<property name="url" stdset="0">
|
||||
<string>https://scilog.psi.ch/login</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DapComboBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dap_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>StopButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>stop_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DarkModeButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dark_mode_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>PositionerGroup</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>positioner_group</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECWaveformWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_waveform_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>LMFitDialog</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>lm_fit_dialog</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>toggle_switch</sender>
|
||||
<signal>enabled(bool)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>toogle_roi_select(bool)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1042</x>
|
||||
<y>212</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>322</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>bec_waveform_widget</sender>
|
||||
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
|
||||
<receiver>lm_fit_dialog</receiver>
|
||||
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1099</x>
|
||||
<y>258</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1157</x>
|
||||
<y>929</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>plot(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>427</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_y_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>dap_combo_box</sender>
|
||||
<signal>new_dap_config(QString,QString,QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>add_dap(QString,QString,QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>447</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>positioner_group</receiver>
|
||||
<slot>set_positioners(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>230</x>
|
||||
<y>306</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>187</x>
|
||||
<y>926</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>set_x(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>972</x>
|
||||
<y>509</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_x_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>794</x>
|
||||
<y>202</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
84
bec_widgets/applications/bec_app.py
Normal file
84
bec_widgets/applications/bec_app.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Launcher for BEC GUI Applications
|
||||
|
||||
Application must be located in bec_widgets/applications ;
|
||||
in order for the launcher to find the application, it has to be put in
|
||||
a subdirectory with the same name as the main Python module:
|
||||
|
||||
/bec_widgets/applications
|
||||
├── alignment
|
||||
│ └── alignment_1d
|
||||
│ └── alignment_1d.py
|
||||
├── other_app
|
||||
└── other_app.py
|
||||
|
||||
The tree above would contain 2 applications, alignment_1d and other_app.
|
||||
|
||||
The Python module for the application must have `if __name__ == "__main__":`
|
||||
in order for the launcher to execute it (it is run with `python -m`).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
MODULE_PATH = os.path.dirname(__file__)
|
||||
|
||||
|
||||
def find_apps(base_dir: str) -> list[str]:
|
||||
matching_modules = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
parent_dir = os.path.basename(root)
|
||||
|
||||
for file in files:
|
||||
if file.endswith(".py") and file != "__init__.py":
|
||||
file_name_without_ext = os.path.splitext(file)[0]
|
||||
|
||||
if file_name_without_ext == parent_dir:
|
||||
rel_path = os.path.relpath(root, base_dir)
|
||||
module_path = rel_path.replace(os.sep, ".")
|
||||
|
||||
module_name = f"{module_path}.{file_name_without_ext}"
|
||||
matching_modules.append((file_name_without_ext, module_name))
|
||||
|
||||
return matching_modules
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="BEC application launcher")
|
||||
|
||||
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
|
||||
|
||||
# Add a positional argument for the module, which acts as a fallback if -m is not provided
|
||||
parser.add_argument(
|
||||
"positional_module",
|
||||
nargs="?", # This makes the positional argument optional
|
||||
help="Positional argument that is treated as module if -m is not specified.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
# If the -m/--module is not provided, fallback to the positional argument
|
||||
module = args.module if args.module else args.positional_module
|
||||
|
||||
if module:
|
||||
for app_name, app_module in find_apps(MODULE_PATH):
|
||||
if module in (app_name, app_module):
|
||||
print("Starting:", app_name)
|
||||
python_executable = sys.executable
|
||||
|
||||
# Replace the current process with the new Python module
|
||||
os.execvp(
|
||||
python_executable,
|
||||
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
|
||||
)
|
||||
print(f"Error: cannot find application {module}")
|
||||
|
||||
# display list of apps
|
||||
print("Available applications:")
|
||||
for app, _ in find_apps(MODULE_PATH):
|
||||
print(f" - {app}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
def dock_area(object_name: str | None = None) -> BECDockArea:
|
||||
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
|
||||
return _dock_area
|
||||
|
||||
|
||||
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
|
||||
"""
|
||||
Create a dock area with auto update enabled.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the dock area.
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created dock area.
|
||||
"""
|
||||
_auto_update = AutoUpdates(object_name=object_name)
|
||||
return _auto_update
|
||||
@@ -1,583 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
DEFAULT_SIZE = (250, 300)
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None = None,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
tile_size: tuple[int, int] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, orientation="vertical")
|
||||
|
||||
# Provide a per‑instance TILE_SIZE so the class can compute layout
|
||||
if tile_size is None:
|
||||
tile_size = self.DEFAULT_SIZE
|
||||
self.tile_size = tile_size
|
||||
|
||||
self.icon_label = QLabel(parent=self)
|
||||
self.icon_label.setFixedSize(100, 100)
|
||||
self.icon_label.setScaledContents(True)
|
||||
pixmap = QPixmap(icon_path)
|
||||
if not pixmap.isNull():
|
||||
size = 100
|
||||
circular_pixmap = QPixmap(size, size)
|
||||
circular_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(circular_pixmap)
|
||||
painter.setRenderHints(QPainter.Antialiasing, True)
|
||||
path = QPainterPath()
|
||||
path.addEllipse(0, 0, size, size)
|
||||
painter.setClipPath(path)
|
||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
|
||||
self.icon_label.setPixmap(circular_pixmap)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Top label
|
||||
self.top_label = QLabel(top_label.upper())
|
||||
font_top = self.top_label.font()
|
||||
font_top.setPointSize(10)
|
||||
self.top_label.setFont(font_top)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
|
||||
# Desired default appearance
|
||||
font_main = self.main_label.font()
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
self.tile_size[0]
|
||||
- self.layout.contentsMargins().left()
|
||||
- self.layout.contentsMargins().right()
|
||||
)
|
||||
self._fit_label_to_width(self.main_label, content_width)
|
||||
|
||||
# Give every tile the same reserved height for the title so the
|
||||
# description labels start at an identical y‑offset.
|
||||
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
|
||||
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.layout.addItem(self.spacer_top)
|
||||
|
||||
# Description
|
||||
self.description_label = QLabel(description)
|
||||
self.description_label.setWordWrap(True)
|
||||
self.description_label.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.description_label)
|
||||
|
||||
# Selector
|
||||
if show_selector:
|
||||
self.selector = QComboBox(self)
|
||||
self.layout.addWidget(self.selector)
|
||||
else:
|
||||
self.selector = None
|
||||
|
||||
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.layout.addItem(self.spacer_bottom)
|
||||
|
||||
# Action button
|
||||
self.action_button = QPushButton("Open")
|
||||
self.action_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #007AFF;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #005BB5;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
Fit the label text to the specified maximum width by adjusting the font size.
|
||||
|
||||
Args:
|
||||
label(QLabel): The label to adjust.
|
||||
max_width(int): The maximum width the label can occupy.
|
||||
min_pt(int): The minimum font point size to use.
|
||||
"""
|
||||
font = label.font()
|
||||
for pt in range(font.pointSize(), min_pt - 1, -1):
|
||||
font.setPointSize(pt)
|
||||
metrics = QFontMetrics(font)
|
||||
if metrics.horizontalAdvance(label.text()) <= max_width:
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
return
|
||||
# If nothing fits, fall back to eliding
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
TILE_SIZE = (250, 300)
|
||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||
|
||||
def __init__(
|
||||
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
# Track the smallest main‑label font size chosen so far
|
||||
self._min_main_label_pt: int | None = None
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget(self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
# Main Widget
|
||||
self.central_widget = QWidget(self)
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.register_tile(
|
||||
name="dock_area",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
top_label="Get started",
|
||||
main_label="BEC Dock Area",
|
||||
description="Highly flexible and customizable dock area application with modular widgets.",
|
||||
action_button=lambda: self.launch("dock_area"),
|
||||
show_selector=False,
|
||||
)
|
||||
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
self.register_tile(
|
||||
name="auto_update",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
|
||||
top_label="Get automated",
|
||||
main_label="BEC Auto Update Dock Area",
|
||||
description="Dock area with auto update functionality for BEC widgets plotting.",
|
||||
action_button=self._open_auto_update,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
|
||||
)
|
||||
|
||||
self.register_tile(
|
||||
name="custom_ui_file",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
|
||||
top_label="Get customized",
|
||||
main_label="Launch Custom UI File",
|
||||
description="GUI application with custom UI file.",
|
||||
action_button=self._open_custom_ui_file,
|
||||
show_selector=False,
|
||||
)
|
||||
|
||||
# plugin widgets
|
||||
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
|
||||
if self.available_widgets:
|
||||
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
|
||||
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
|
||||
self.register_tile(
|
||||
name="widget",
|
||||
icon_path=os.path.join(
|
||||
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
|
||||
),
|
||||
top_label="Get quickly started",
|
||||
main_label=f"Launch a {plugin_repo_name} Widget",
|
||||
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
|
||||
action_button=self._open_widget,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_widgets.keys()),
|
||||
)
|
||||
|
||||
self._update_theme()
|
||||
|
||||
self.register = RPCRegister()
|
||||
self.register.callbacks.append(self._turn_off_the_lights)
|
||||
self.register.broadcast()
|
||||
|
||||
def register_tile(
|
||||
self,
|
||||
name: str,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
action_button: Callable | None = None,
|
||||
show_selector: bool = False,
|
||||
selector_items: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Register a tile in the launcher window.
|
||||
|
||||
Args:
|
||||
name(str): The name of the tile.
|
||||
icon_path(str): The path to the icon.
|
||||
top_label(str): The top label of the tile.
|
||||
main_label(str): The main label of the tile.
|
||||
description(str): The description of the tile.
|
||||
action_button(callable): The action to be performed when the button is clicked.
|
||||
show_selector(bool): Whether to show a selector or not.
|
||||
selector_items(list[str]): The items to be shown in the selector.
|
||||
"""
|
||||
|
||||
tile = LaunchTile(
|
||||
icon_path=icon_path,
|
||||
top_label=top_label,
|
||||
main_label=main_label,
|
||||
description=description,
|
||||
show_selector=show_selector,
|
||||
tile_size=self.TILE_SIZE,
|
||||
)
|
||||
tile.setFixedWidth(self.TILE_SIZE[0])
|
||||
tile.setMinimumHeight(self.TILE_SIZE[1])
|
||||
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
|
||||
if action_button:
|
||||
tile.action_button.clicked.connect(action_button)
|
||||
if show_selector and selector_items:
|
||||
tile.selector.addItems(selector_items)
|
||||
self.central_widget.layout.addWidget(tile)
|
||||
|
||||
# keep all tiles' main labels at a unified point size
|
||||
current_pt = tile.main_label.font().pointSize()
|
||||
if self._min_main_label_pt is None or current_pt < self._min_main_label_pt:
|
||||
# New global minimum – shrink every existing tile to this size
|
||||
self._min_main_label_pt = current_pt
|
||||
for t in self.tiles.values():
|
||||
f = t.main_label.font()
|
||||
f.setPointSize(self._min_main_label_pt)
|
||||
t.main_label.setFont(f)
|
||||
t.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
|
||||
elif current_pt > self._min_main_label_pt:
|
||||
# Tile is larger than global minimum – shrink it to match
|
||||
f = tile.main_label.font()
|
||||
f.setPointSize(self._min_main_label_pt)
|
||||
tile.main_label.setFont(f)
|
||||
tile.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
|
||||
|
||||
self.tiles[name] = tile
|
||||
|
||||
def launch(
|
||||
self,
|
||||
launch_script: str,
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
**kwargs,
|
||||
) -> QWidget | None:
|
||||
"""Launch the specified script. If the launch script creates a QWidget, it will be
|
||||
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
|
||||
as a separate window.
|
||||
|
||||
Args:
|
||||
launch_script(str): The name of the script to be launched.
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
QWidget: The created dock area.
|
||||
"""
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
if name in existing_dock_areas:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
|
||||
if launch_script is None:
|
||||
launch_script = "dock_area"
|
||||
if not isinstance(launch_script, str):
|
||||
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
|
||||
|
||||
if launch_script == "custom_ui_file":
|
||||
ui_file = kwargs.pop("ui_file", None)
|
||||
if not ui_file:
|
||||
return None
|
||||
return self._launch_custom_ui_file(ui_file)
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update)
|
||||
|
||||
if launch_script == "widget":
|
||||
widget = kwargs.pop("widget", None)
|
||||
if widget is None:
|
||||
raise ValueError("Widget name must be provided.")
|
||||
return self._launch_widget(widget)
|
||||
|
||||
launch = getattr(bw_launch, launch_script, None)
|
||||
if launch is None:
|
||||
raise ValueError(f"Launch script {launch_script} not found.")
|
||||
|
||||
result_widget = launch(name)
|
||||
result_widget.resize(result_widget.minimumSizeHint())
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
|
||||
if geometry is not None:
|
||||
result_widget.setGeometry(*geometry)
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindow()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
|
||||
# Load the custom UI file
|
||||
if ui_file is None:
|
||||
raise ValueError("UI file must be provided for custom UI file launch.")
|
||||
filename = os.path.basename(ui_file).split(".")[0]
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(filename)
|
||||
|
||||
tree = ET.parse(ui_file)
|
||||
root = tree.getroot()
|
||||
# Check if the top-level widget is a QMainWindow
|
||||
widget = root.find("widget")
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
if widget.attrib.get("class") == "QMainWindow":
|
||||
raise ValueError(
|
||||
"Loading a QMainWindow from a UI file is currently not supported. "
|
||||
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
|
||||
)
|
||||
|
||||
window = UILaunchWindow(object_name=filename)
|
||||
QApplication.processEvents()
|
||||
result_widget = UILoader(window).loader(ui_file)
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {window.object_name}")
|
||||
window.show()
|
||||
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
if auto_update in self.available_auto_updates:
|
||||
auto_update_cls = self.available_auto_updates[auto_update]
|
||||
window = auto_update_cls()
|
||||
else:
|
||||
|
||||
auto_update = "auto_updates"
|
||||
window = AutoUpdates()
|
||||
|
||||
window.resize(window.minimumSizeHint())
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {window.objectName()}")
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
window = BECMainWindow()
|
||||
|
||||
widget_instance = widget(root_widget=True, object_name=name)
|
||||
assert isinstance(widget_instance, QWidget)
|
||||
QApplication.processEvents()
|
||||
|
||||
window.setCentralWidget(widget_instance)
|
||||
window.resize(window.minimumSizeHint())
|
||||
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
"""
|
||||
for tile in self.tiles.values():
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
|
||||
def _open_auto_update(self):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tiles["auto_update"].selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tiles["auto_update"].selector.currentText()
|
||||
if auto_update == "Default":
|
||||
auto_update = None
|
||||
return self.launch("auto_update", auto_update=auto_update)
|
||||
|
||||
def _open_widget(self):
|
||||
"""
|
||||
Open a widget from the available widgets.
|
||||
"""
|
||||
if self.tiles["widget"].selector is None:
|
||||
return
|
||||
widget = self.tiles["widget"].selector.currentText()
|
||||
if widget not in self.available_widgets:
|
||||
raise ValueError(f"Widget {widget} not found in available widgets.")
|
||||
return self.launch("widget", widget=self.available_widgets[widget])
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
"""
|
||||
Open a file dialog to select a custom UI file and launch it.
|
||||
"""
|
||||
ui_file, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
|
||||
)
|
||||
self.launch("custom_ui_file", ui_file=ui_file)
|
||||
|
||||
@staticmethod
|
||||
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Load all available auto updates from the plugin repository.
|
||||
"""
|
||||
try:
|
||||
auto_updates = get_plugin_auto_updates()
|
||||
logger.info(f"Available auto updates: {auto_updates.keys()}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load auto updates: {exc}")
|
||||
return {}
|
||||
return auto_updates
|
||||
|
||||
def show_launcher(self):
|
||||
"""
|
||||
Show the launcher window.
|
||||
"""
|
||||
self.show()
|
||||
|
||||
def hide_launcher(self):
|
||||
"""
|
||||
Hide the launcher window.
|
||||
"""
|
||||
self.hide()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
def _launcher_is_last_widget(self, connections: dict) -> bool:
|
||||
"""
|
||||
Check if the launcher is the last widget in the application.
|
||||
"""
|
||||
|
||||
remaining_connections = [
|
||||
connection for connection in connections.values() if connection.parent_id != self.gui_id
|
||||
]
|
||||
return len(remaining_connections) <= 4
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
if self._launcher_is_last_widget(connections):
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
return
|
||||
|
||||
self.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Close the launcher window.
|
||||
"""
|
||||
connections = self.register.list_all_connections()
|
||||
if self._launcher_is_last_widget(connections):
|
||||
event.accept()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
self.hide()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
launcher = LaunchWindow()
|
||||
launcher.show()
|
||||
sys.exit(app.exec())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1 @@
|
||||
from .client import *
|
||||
|
||||
168
bec_widgets/cli/auto_updates.py
Normal file
168
bec_widgets/cli/auto_updates.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from queue import Queue
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import BECDockArea, BECFigure
|
||||
|
||||
|
||||
class ScanInfo(BaseModel):
|
||||
scan_id: str
|
||||
scan_number: int
|
||||
scan_name: str
|
||||
scan_report_devices: list
|
||||
monitored_devices: list
|
||||
status: str
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class AutoUpdates:
|
||||
create_default_dock: bool = False
|
||||
enabled: bool = False
|
||||
dock_name: str = None
|
||||
|
||||
def __init__(self, gui: BECDockArea):
|
||||
self.gui = gui
|
||||
self._default_dock = None
|
||||
self._default_fig = None
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
self.dock_name = "default_figure"
|
||||
self._default_dock = self.gui.add_dock(self.dock_name)
|
||||
self._default_dock.add_widget("BECFigure")
|
||||
self._default_fig = self._default_dock.widget_list[0]
|
||||
|
||||
@staticmethod
|
||||
def get_scan_info(msg) -> ScanInfo:
|
||||
"""
|
||||
Update the script with the given data.
|
||||
"""
|
||||
info = msg.info
|
||||
status = msg.status
|
||||
scan_id = msg.scan_id
|
||||
scan_number = info.get("scan_number", 0)
|
||||
scan_name = info.get("scan_name", "Unknown")
|
||||
scan_report_devices = info.get("scan_report_devices", [])
|
||||
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
|
||||
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
|
||||
return ScanInfo(
|
||||
scan_id=scan_id,
|
||||
scan_number=scan_number,
|
||||
scan_name=scan_name,
|
||||
scan_report_devices=scan_report_devices,
|
||||
monitored_devices=monitored_devices,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def get_default_figure(self) -> BECFigure | None:
|
||||
"""
|
||||
Get the default figure from the GUI.
|
||||
"""
|
||||
return self._default_fig
|
||||
|
||||
def do_update(self, msg):
|
||||
"""
|
||||
Run the update function if enabled.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
if msg.status != "open":
|
||||
return
|
||||
info = self.get_scan_info(msg)
|
||||
return self.handler(info)
|
||||
|
||||
def get_selected_device(self, monitored_devices, selected_device):
|
||||
"""
|
||||
Get the selected device for the plot. If no device is selected, the first
|
||||
device in the monitored devices list is selected.
|
||||
"""
|
||||
if selected_device:
|
||||
return selected_device
|
||||
if len(monitored_devices) > 0:
|
||||
sel_device = monitored_devices[0]
|
||||
return sel_device
|
||||
return None
|
||||
|
||||
def handler(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Default update function.
|
||||
"""
|
||||
if info.scan_name == "line_scan" and info.scan_report_devices:
|
||||
return self.simple_line_scan(info)
|
||||
if info.scan_name == "grid_scan" and info.scan_report_devices:
|
||||
return self.simple_grid_scan(info)
|
||||
if info.scan_report_devices:
|
||||
return self.best_effort(info)
|
||||
|
||||
def simple_line_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Simple line scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
def simple_grid_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Simple grid scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = info.scan_report_devices[1]
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_z = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
z_name=dev_z,
|
||||
label=f"Scan {info.scan_number} - {dev_z}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
def best_effort(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Best effort scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
selected_device = yield self.gui.selected_device
|
||||
dev_y = self.get_selected_device(info.monitored_devices, selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
yield fig.clear_all()
|
||||
yield fig.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,40 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as imd
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import msgpack
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import GUIRegistryStateMessage
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
else:
|
||||
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
str | bool | dict,
|
||||
]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
|
||||
|
||||
def _filter_output(output: str) -> str:
|
||||
"""
|
||||
@@ -74,11 +71,12 @@ def _get_output(process, logger) -> None:
|
||||
|
||||
def _start_plot_process(
|
||||
gui_id: str,
|
||||
gui_class_id: str,
|
||||
gui_class: type,
|
||||
config: dict | str,
|
||||
gui_class: str = "dock_area",
|
||||
logger=None, # FIXME change gui_class back to "launcher" later
|
||||
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
|
||||
acl_data: bytes | None = None,
|
||||
token: bytes | None = None,
|
||||
logger=None,
|
||||
) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
@@ -87,16 +85,7 @@ def _start_plot_process(
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
@@ -104,6 +93,8 @@ def _start_plot_process(
|
||||
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
env_dict["BEC_GUI_ACL"] = acl_data or b""
|
||||
env_dict["BEC_GUI_TOKEN"] = token or b""
|
||||
|
||||
if logger is None:
|
||||
stdout_redirect = subprocess.DEVNULL
|
||||
@@ -131,20 +122,16 @@ def _start_plot_process(
|
||||
|
||||
|
||||
class RepeatTimer(threading.Timer):
|
||||
"""RepeatTimer class."""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@contextmanager
|
||||
def wait_for_server(client: BECGuiClient):
|
||||
"""Context manager to wait for the server to start."""
|
||||
def wait_for_server(client):
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client._gui_is_alive():
|
||||
if client.gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
@@ -162,167 +149,233 @@ def wait_for_server(client: BECGuiClient):
|
||||
yield
|
||||
|
||||
|
||||
class WidgetNameSpace:
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr, value in self.__dict__.items():
|
||||
docs = value.__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr, docs)
|
||||
console.print(table)
|
||||
return ""
|
||||
### ----------------------------
|
||||
### NOTE
|
||||
### it is far easier to extend the 'delete' method on the client side,
|
||||
### to know when the client is deleted, rather than listening to server
|
||||
### to get notified. However, 'generate_cli.py' cannot add extra stuff
|
||||
### in the generated client module. So, here a class with the same name
|
||||
### is created, and client module is patched.
|
||||
class BECDockArea(client.BECDockArea):
|
||||
def delete(self):
|
||||
if self is BECGuiClient._top_level["main"].widget:
|
||||
raise RuntimeError("Cannot delete main window")
|
||||
super().delete()
|
||||
try:
|
||||
del BECGuiClient._top_level[self._gui_id]
|
||||
except KeyError:
|
||||
# if a dock area is not at top level
|
||||
pass
|
||||
|
||||
|
||||
class AvailableWidgetsNamespace:
|
||||
"""Namespace for available widgets in the BEC GUI."""
|
||||
client.BECDockArea = BECDockArea
|
||||
### ----------------------------
|
||||
|
||||
def __init__(self):
|
||||
for widget in client.Widgets:
|
||||
name = widget.value
|
||||
if name in IGNORE_WIDGETS:
|
||||
continue
|
||||
setattr(self, name, name)
|
||||
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr_name, _ in self.__dict__.items():
|
||||
docs = getattr(client, attr_name).__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
|
||||
console.print(table)
|
||||
return ""
|
||||
@dataclass
|
||||
class WidgetDesc:
|
||||
title: str
|
||||
widget: BECDockArea
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
"""BEC GUI client class. Container for GUI applications within Python."""
|
||||
_top_level = {}
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._lock = Lock()
|
||||
self._anchor_widget = "launcher"
|
||||
self._killed = False
|
||||
self._top_level: dict[str, RPCReference] = {}
|
||||
self._auto_updates_enabled = True
|
||||
self._auto_updates = None
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
self._server_registry: dict[str, RegistryState] = {}
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
####################
|
||||
self._fernet = None
|
||||
|
||||
@property
|
||||
def launcher(self) -> RPCBase:
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
def windows(self):
|
||||
return self._top_level
|
||||
|
||||
def connect_to_gui_server(self, gui_id: str) -> None:
|
||||
"""Connect to a GUI server"""
|
||||
# Unregister the old callback
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
@property
|
||||
def auto_updates(self):
|
||||
if self._auto_updates_enabled:
|
||||
with wait_for_server(self):
|
||||
return self._auto_updates
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
for ep in eps:
|
||||
if ep.name == "plugin_widgets_update":
|
||||
try:
|
||||
spec = importlib.util.find_spec(ep.module)
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self._top_level["main"].widget)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
|
||||
auto_update_config = self._client.connector.get(auto_update_config_ep)
|
||||
if auto_update_config:
|
||||
return auto_update_config.selected_device
|
||||
return None
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, device: str | DeviceBase):
|
||||
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
self._client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
|
||||
)
|
||||
elif isinstance(device, str):
|
||||
self._client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_auto_update_config(self._gui_id),
|
||||
messages.GUIAutoUpdateConfigMessage(selected_device=device),
|
||||
)
|
||||
else:
|
||||
raise ValueError("Device must be a string or a device object")
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
|
||||
|
||||
def _handle_msg_update(self, msg: MessageObject) -> None:
|
||||
if self.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
return self._update_script_msg_parser(msg.value)
|
||||
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
if self._auto_updates_enabled:
|
||||
return self.auto_updates.do_update(msg)
|
||||
|
||||
def _gui_post_startup(self):
|
||||
self._top_level["main"] = WidgetDesc(
|
||||
title="BEC Widgets", widget=BECDockArea(gui_id=self._gui_id)
|
||||
)
|
||||
self._gui_id = gui_id
|
||||
if self._auto_updates_enabled:
|
||||
if self._auto_updates is None:
|
||||
auto_updates = self._get_update_script()
|
||||
if auto_updates is None:
|
||||
AutoUpdates.create_default_dock = True
|
||||
AutoUpdates.enabled = True
|
||||
auto_updates = AutoUpdates(self._top_level["main"].widget)
|
||||
if auto_updates.create_default_dock:
|
||||
auto_updates.start_default_dock()
|
||||
self._start_update_script()
|
||||
self._auto_updates = auto_updates
|
||||
self._do_show_all()
|
||||
self._gui_started_event.set()
|
||||
|
||||
# reset the namespace
|
||||
self._update_dynamic_namespace({})
|
||||
self._server_registry = {}
|
||||
self._top_level = {}
|
||||
self._ipython_registry = {}
|
||||
# def _update_gui_acls(self, username: str, password: str | None):
|
||||
# self._client.connector.send(
|
||||
# MessageEndpoints.gui_acls(self._gui_id),
|
||||
# messages.CredentialsMessage(
|
||||
# credentials={
|
||||
# "token": self._fernet.encrypt(
|
||||
# msgpack.dumps({"username": username, "password": password})
|
||||
# )
|
||||
# }
|
||||
# ),
|
||||
# )
|
||||
|
||||
# Register the new callback
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
from_start=True,
|
||||
)
|
||||
def start_server(self, wait=False) -> None:
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
encr_token = Fernet.generate_key()
|
||||
self._fernet = Fernet(encr_token)
|
||||
conn = self._client.connector._redis_conn.connection_pool.connection_kwargs
|
||||
acl_data = {"username": conn.get("username"), "password": conn.get("password")}
|
||||
acl_data = self._fernet.encrypt(msgpack.dumps(acl_data))
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id,
|
||||
self.__class__,
|
||||
self._client._service_config.config,
|
||||
acl_data=acl_data,
|
||||
token=encr_token,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
@property
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock areas in the GUI."""
|
||||
return {widget.object_name: widget for widget in self._top_level.values()}
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel()
|
||||
|
||||
@property
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
return self._start(wait=wait)
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def _dump(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
return rpc_client._run_rpc("_dump")
|
||||
|
||||
def start(self):
|
||||
return self.start_server()
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("show")
|
||||
for window in self._top_level.values():
|
||||
window.widget.show()
|
||||
|
||||
def show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
rpc_client._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.widget.hide()
|
||||
|
||||
def show(self):
|
||||
"""Show the GUI window."""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
return self.start(wait=True)
|
||||
if self._process is not None:
|
||||
return self.show_all()
|
||||
# backward compatibility: show() was also starting server
|
||||
return self.start_server(wait=True)
|
||||
|
||||
def hide(self):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
return self.hide_all()
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
launch_script: str = "dock_area",
|
||||
**kwargs,
|
||||
) -> client.BECDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
@property
|
||||
def main(self):
|
||||
"""Return client to main dock area (in main window)"""
|
||||
with wait_for_server(self):
|
||||
return self._top_level["main"].widget
|
||||
|
||||
Args:
|
||||
name(str, optional): The name of the dock area. Defaults to None.
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
|
||||
Returns:
|
||||
client.BECDockArea: The new dock area.
|
||||
def new(self, title):
|
||||
"""Ask main window to create a new top-level dock area"""
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
|
||||
widget = rpc_client._run_rpc("new_dock_area", title)
|
||||
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
|
||||
return widget
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
Close the gui window.
|
||||
"""
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
for widget_name in self.windows:
|
||||
self.delete(widget_name)
|
||||
|
||||
def kill_server(self) -> None:
|
||||
"""Kill the GUI server."""
|
||||
# Unregister the registry state
|
||||
self._killed = True
|
||||
self._top_level.clear()
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
@@ -338,194 +391,3 @@ class BECGuiClient(RPCBase):
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
# Remove all reference from top level
|
||||
self._top_level.clear()
|
||||
self._server_registry.clear()
|
||||
|
||||
def close(self):
|
||||
"""Deprecated. Use kill_server() instead."""
|
||||
# FIXME, deprecated in favor of kill, will be removed in the future
|
||||
self.kill_server()
|
||||
|
||||
#########################
|
||||
#### Private methods ####
|
||||
#########################
|
||||
|
||||
def _check_if_server_is_alive(self):
|
||||
"""Checks if the process is alive"""
|
||||
if self._process is None:
|
||||
return False
|
||||
if self._process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _gui_post_startup(self):
|
||||
timeout = 60
|
||||
# Wait for 'bec' gui to be registered, this may take some time
|
||||
# After 60s timeout. Should this raise an exception on timeout?
|
||||
while time.time() < time.time() + timeout:
|
||||
if len(list(self._server_registry.keys())) < 2 or not hasattr(
|
||||
self, self._anchor_widget
|
||||
):
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
|
||||
self._gui_started_event.set()
|
||||
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id,
|
||||
gui_class_id="bec",
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel() # type: ignore
|
||||
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def _start(self, wait: bool = False) -> None:
|
||||
self._killed = False
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
@staticmethod
|
||||
def _handle_registry_update(
|
||||
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
||||
) -> None:
|
||||
# This was causing a deadlock during shutdown, not sure why.
|
||||
# with self._lock:
|
||||
self = parent
|
||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
Update the dynamic name space with the given server registry.
|
||||
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
|
||||
|
||||
Args:
|
||||
server_registry (dict): The server registry
|
||||
"""
|
||||
top_level_widgets: dict[str, RPCReference] = {}
|
||||
for gui_id, state in server_registry.items():
|
||||
widget = self._add_widget(state, self)
|
||||
if widget is None:
|
||||
# ignore widgets that are not supported
|
||||
continue
|
||||
# get all top-level widgets. These are widgets that have no parent
|
||||
if not state["config"].get("parent_id"):
|
||||
top_level_widgets[gui_id] = widget
|
||||
|
||||
remove_from_registry = []
|
||||
for gui_id, widget in self._ipython_registry.items():
|
||||
if gui_id not in server_registry:
|
||||
remove_from_registry.append(gui_id)
|
||||
for gui_id in remove_from_registry:
|
||||
self._ipython_registry.pop(gui_id)
|
||||
|
||||
removed_widgets = [
|
||||
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
|
||||
]
|
||||
|
||||
for widget_name in removed_widgets:
|
||||
# the check is not strictly necessary, but better safe
|
||||
# than sorry; who knows what the user has done
|
||||
if hasattr(self, widget_name):
|
||||
delattr(self, widget_name)
|
||||
|
||||
for gui_id, widget_ref in top_level_widgets.items():
|
||||
setattr(self, widget_ref.object_name, widget_ref)
|
||||
|
||||
self._top_level = top_level_widgets
|
||||
|
||||
for widget in self._ipython_registry.values():
|
||||
widget._refresh_references()
|
||||
|
||||
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
|
||||
"""Add a widget to the namespace
|
||||
|
||||
Args:
|
||||
state (dict): The state of the widget from the _server_registry.
|
||||
parent (object): The parent object.
|
||||
"""
|
||||
object_name = state["object_name"]
|
||||
gui_id = state["gui_id"]
|
||||
if state["widget_class"] in IGNORE_WIDGETS:
|
||||
return
|
||||
widget_class = getattr(client, state["widget_class"], None)
|
||||
if widget_class is None:
|
||||
return
|
||||
obj = self._ipython_registry.get(gui_id)
|
||||
if obj is None:
|
||||
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
|
||||
self._ipython_registry[gui_id] = widget
|
||||
else:
|
||||
widget = obj
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
|
||||
return obj
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
|
||||
try:
|
||||
config = ServiceConfig()
|
||||
bec_client = BECClient(config)
|
||||
bec_client.start()
|
||||
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.start(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
gui.kill_server()
|
||||
|
||||
@@ -2,22 +2,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import black
|
||||
import isort
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property as QtProperty
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
@@ -34,29 +29,13 @@ else:
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self, base=False):
|
||||
self._base = base
|
||||
base_imports = (
|
||||
"""import enum
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
else "\n"
|
||||
)
|
||||
self.header = f"""# This file was automatically generated by generate_cli.py
|
||||
# type: ignore \n
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
@@ -83,7 +62,6 @@ logger = bec_logger.logger
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
logger.debug(f"generating RPC client class for {cls.__name__}")
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
@@ -91,50 +69,14 @@ logger = bec_logger.logger
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||
...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
|
||||
_Widgets = {
|
||||
class Widgets(str, enum.Enum):
|
||||
\"\"\"
|
||||
Enum for the available widgets.
|
||||
\"\"\"
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
|
||||
|
||||
self.content += """}
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
globals()[plugin_name] = plugin_class
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||
"""
|
||||
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
@@ -153,21 +95,9 @@ class {class_name}(RPCBase):"""
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
# But skip the first line if it's a blank line
|
||||
first_line = cls.__doc__.split("\n")[0]
|
||||
if first_line:
|
||||
class_docs = first_line
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method in cls.USER_ACCESS:
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
@@ -223,18 +153,18 @@ class {class_name}(RPCBase):"""
|
||||
# Combine header and content, then format with black
|
||||
full_content = self.header + "\n" + self.content
|
||||
try:
|
||||
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
|
||||
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
config = isort.Config(
|
||||
isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=False,
|
||||
include_trailing_comma=True,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
@@ -246,78 +176,41 @@ def main():
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="store",
|
||||
type=str,
|
||||
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
|
||||
)
|
||||
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.target is None:
|
||||
logger.error(
|
||||
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"BEC Widget code generation tool started with args: {args}")
|
||||
if args.core:
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
|
||||
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
|
||||
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
|
||||
rpc_classes = get_custom_classes("bec_widgets")
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
assert module.__file__ is not None
|
||||
module_file = Path(module.__file__)
|
||||
module_dir = module_file.parent if module_file.is_file() else module_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {module_name} for code generation: {e}")
|
||||
return
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
for cls in rpc_classes.plugins:
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
rpc_classes = get_custom_classes(module_name)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
logger.info(f"Generating client file at {client_path}")
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(str(client_path))
|
||||
|
||||
if module_name != "bec_widgets":
|
||||
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
|
||||
logger.info(
|
||||
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
|
||||
)
|
||||
else:
|
||||
non_overwrite_classes = []
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
|
||||
if cls.__name__ in non_overwrite_classes:
|
||||
logger.error(
|
||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||
)
|
||||
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
def _exists(file: str):
|
||||
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
||||
|
||||
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
||||
logger.debug(
|
||||
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
||||
)
|
||||
continue
|
||||
|
||||
plugin.run()
|
||||
# if the class directory already has a register, plugin and pyproject file, skip
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
|
||||
):
|
||||
continue
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
main()
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
import bec_widgets.cli.client as client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
else:
|
||||
client = lazy_import("bec_widgets.cli.client") # avoid circular import
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
@@ -39,14 +35,6 @@ def rpc_call(func):
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
caller_frame = inspect.currentframe().f_back # type: ignore
|
||||
while caller_frame:
|
||||
if "jedi" in caller_frame.f_globals:
|
||||
# Jedi module is present, likely tab completion
|
||||
# Do not run the RPC call
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
@@ -56,7 +44,7 @@ def rpc_call(func):
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self._root._gui_is_alive():
|
||||
if not self.gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
@@ -72,96 +60,24 @@ class RPCResponseTimeoutError(Exception):
|
||||
)
|
||||
|
||||
|
||||
class DeletedWidgetError(Exception): ...
|
||||
|
||||
|
||||
def check_for_deleted_widget(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCReference:
|
||||
def __init__(self, registry: dict, gui_id: str) -> None:
|
||||
self._registry = registry
|
||||
self._gui_id = gui_id
|
||||
self.object_name = self._registry[self._gui_id].object_name
|
||||
|
||||
@check_for_deleted_widget
|
||||
def __getattr__(self, name):
|
||||
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
|
||||
return super().__getattribute__(name)
|
||||
return self._registry[self._gui_id].__getattribute__(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
|
||||
return super().__setattr__(name, value)
|
||||
if self._gui_id not in self._registry:
|
||||
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
|
||||
self._registry[self._gui_id].__setattr__(name, value)
|
||||
|
||||
def __repr__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__repr__()
|
||||
|
||||
def __str__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return f"<Deleted widget with gui_id {self._gui_id}>"
|
||||
return self._registry[self._gui_id].__str__()
|
||||
|
||||
def __dir__(self):
|
||||
if self._gui_id not in self._registry:
|
||||
return []
|
||||
return self._registry[self._gui_id].__dir__()
|
||||
|
||||
def _is_deleted(self) -> bool:
|
||||
return self._gui_id not in self._registry
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str | None = None,
|
||||
config: dict | None = None,
|
||||
object_name: str | None = None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
|
||||
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
|
||||
self._parent = parent
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
self._rpc_references: dict[str, str] = {}
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} with name: {self.object_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
if proxy := obj.get("container_proxy"):
|
||||
assert isinstance(proxy, str)
|
||||
self._run_rpc("remove", gui_id=proxy)
|
||||
return
|
||||
self._run_rpc("remove")
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
|
||||
@property
|
||||
def _root(self) -> BECGuiClient:
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
@@ -170,17 +86,9 @@ class RPCBase:
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
return parent
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
*args,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -188,8 +96,6 @@ class RPCBase:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
@@ -198,9 +104,10 @@ class RPCBase:
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
@@ -224,11 +131,7 @@ class RPCBase:
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
|
||||
# we can assume that the response is a RequestResponseMessage, updated by
|
||||
# the _on_rpc_response method
|
||||
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
|
||||
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
@@ -236,10 +139,10 @@ class RPCBase:
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||
parent._rpc_response = msg
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
@@ -258,21 +161,11 @@ class RPCBase:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# The namespace of the object will be updated dynamically on the client side
|
||||
# Therefore it is important to check if the object is already in the registry
|
||||
# If yes, we return the reference to the object, otherwise we create a new object
|
||||
# pylint: disable=protected-access
|
||||
if msg_result["gui_id"] in self._root._ipython_registry:
|
||||
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
|
||||
ret = cls(parent=self, **msg_result)
|
||||
self._root._ipython_registry[ret._gui_id] = ret
|
||||
self._refresh_references()
|
||||
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
|
||||
return obj
|
||||
# return ret
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def _gui_is_alive(self):
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
@@ -282,27 +175,3 @@ class RPCBase:
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _refresh_references(self):
|
||||
"""
|
||||
Refresh the references.
|
||||
"""
|
||||
with self._root._lock:
|
||||
references = {}
|
||||
for key, val in self._root._server_registry.items():
|
||||
parent_id = val["config"].get("parent_id")
|
||||
if parent_id == self._gui_id:
|
||||
references[key] = {
|
||||
"gui_id": val["config"]["gui_id"],
|
||||
"object_name": val["object_name"],
|
||||
}
|
||||
removed_references = set(self._rpc_references.keys()) - set(references.keys())
|
||||
for key in removed_references:
|
||||
delattr(self, self._rpc_references[key]["object_name"])
|
||||
self._rpc_references = references
|
||||
for key, val in references.items():
|
||||
setattr(
|
||||
self,
|
||||
val["object_name"],
|
||||
RPCReference(self._root._ipython_registry, val["gui_id"]),
|
||||
)
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from threading import RLock
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
from threading import Lock
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def broadcast_update(func):
|
||||
"""
|
||||
Decorator to broadcast updates to the RPCRegister whenever a new RPC object is added or removed.
|
||||
If class attribute _skip_broadcast is set to True, the broadcast will be skipped
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
result = func(self, *args, **kwargs)
|
||||
self.broadcast()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
@@ -39,6 +13,7 @@ class RPCRegister:
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
@@ -50,22 +25,9 @@ class RPCRegister:
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._broadcast_on_hold = RPCRegisterBroadcast(self)
|
||||
self._lock = RLock()
|
||||
self._skip_broadcast = False
|
||||
self._initialized = True
|
||||
self.callbacks = []
|
||||
|
||||
@classmethod
|
||||
def delayed_broadcast(cls):
|
||||
"""
|
||||
Delay the broadcast of the update to all the callbacks.
|
||||
"""
|
||||
register = cls()
|
||||
return register._broadcast_on_hold
|
||||
|
||||
@broadcast_update
|
||||
def add_rpc(self, rpc: BECConnector):
|
||||
def add_rpc(self, rpc: QObject):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
@@ -76,8 +38,7 @@ class RPCRegister:
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
@broadcast_update
|
||||
def remove_rpc(self, rpc: BECConnector):
|
||||
def remove_rpc(self, rpc: str):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
@@ -88,7 +49,7 @@ class RPCRegister:
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
@@ -96,7 +57,7 @@ class RPCRegister:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject | None: The RPC object with the given ID or None
|
||||
QObject: The RPC object with the given ID.
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
@@ -112,52 +73,6 @@ class RPCRegister:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
def get_names_of_rpc_by_class_type(
|
||||
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
|
||||
) -> list[str]:
|
||||
"""Get all the names of the widgets.
|
||||
|
||||
Args:
|
||||
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
|
||||
"""
|
||||
# This retrieves any rpc objects that are subclass of BECWidget,
|
||||
# i.e. curve and image items are excluded
|
||||
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
|
||||
return [widget.object_name for widget in widgets]
|
||||
|
||||
def broadcast(self):
|
||||
"""
|
||||
Broadcast the update to all the callbacks.
|
||||
"""
|
||||
|
||||
if self._skip_broadcast:
|
||||
return
|
||||
connections = self.list_all_connections()
|
||||
for callback in self.callbacks:
|
||||
callback(connections)
|
||||
|
||||
def object_is_registered(self, obj: BECConnector) -> bool:
|
||||
"""
|
||||
Check if an object is registered in the RPC register.
|
||||
|
||||
Args:
|
||||
obj(QObject): The object to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the object is registered, False otherwise.
|
||||
"""
|
||||
return obj.gui_id in self._rpc_register
|
||||
|
||||
def add_callback(self, callback: Callable[[dict], None]):
|
||||
"""
|
||||
Add a callback that will be called whenever the registry is updated.
|
||||
|
||||
Args:
|
||||
callback(Callable[[dict], None]): The callback to be added. It should accept a dictionary of all the
|
||||
registered RPC objects as an argument.
|
||||
"""
|
||||
self.callbacks.append(callback)
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
@@ -165,25 +80,3 @@ class RPCRegister:
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
|
||||
class RPCRegisterBroadcast:
|
||||
"""Context manager for RPCRegister broadcast."""
|
||||
|
||||
def __init__(self, rpc_register: RPCRegister) -> None:
|
||||
self.rpc_register = rpc_register
|
||||
self._call_depth = 0
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter the context manager"""
|
||||
self._call_depth += 1 # Needed for nested calls
|
||||
self.rpc_register._skip_broadcast = True
|
||||
return self.rpc_register
|
||||
|
||||
def __exit__(self, *exc):
|
||||
"""Exit the context manager"""
|
||||
|
||||
self._call_depth -= 1 # Remove nested calls
|
||||
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
|
||||
self.rpc_register._skip_broadcast = False
|
||||
self.rpc_register.broadcast()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
@@ -13,7 +10,7 @@ class RPCWidgetHandler:
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
||||
def widget_classes(self):
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
@@ -22,7 +19,7 @@ class RPCWidgetHandler:
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes # type: ignore
|
||||
return self._widget_classes
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
@@ -31,23 +28,25 @@ class RPCWidgetHandler:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
name (str): The name of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECWidget): The created widget.
|
||||
widget(BECConnector): The created widget.
|
||||
"""
|
||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
@@ -1,26 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import types
|
||||
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
|
||||
import msgpack
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from cryptography.fernet import Fernet
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
@contextmanager
|
||||
def rpc_exception_hook(err_func):
|
||||
"""This context replaces the popup message box for error display with a specific hook"""
|
||||
# get error popup utility singleton
|
||||
popup = ErrorPopupUtility()
|
||||
# save current setting
|
||||
old_exception_hook = popup.custom_exception_hook
|
||||
|
||||
# install err_func, if it is a callable
|
||||
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
|
||||
err_func({"error": popup.get_error_message(exc_type, value, tb)})
|
||||
|
||||
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
|
||||
|
||||
try:
|
||||
yield popup
|
||||
finally:
|
||||
# restore state of error popup utility singleton
|
||||
popup.custom_exception_hook = old_exception_hook
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str,
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
token: str = None,
|
||||
) -> None:
|
||||
self._fernet = Fernet(token) if token else None
|
||||
self._init_acls(config)
|
||||
self.status = messages.BECStatus.BUSY
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
self.gui = gui_class(gui_id=self.gui_id)
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self.gui)
|
||||
|
||||
# self.dispatcher.connect_slot(self.on_acl_update, MessageEndpoints.gui_acl(self.gui_id))
|
||||
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
)
|
||||
|
||||
# Setup QTimer for heartbeat
|
||||
self._heartbeat_timer = QTimer()
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200)
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
|
||||
def _init_acls(self, config: ServiceConfig):
|
||||
acl_data = os.getenv("BEC_GUI_ACL")
|
||||
if not acl_data:
|
||||
return
|
||||
acl_data = msgpack.loads(self._fernet.decrypt(acl_data))
|
||||
config.config["acl"] = acl_data
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def on_acl_update(self, msg: dict, metadata: dict):
|
||||
logger.debug(f"Received ACL update: {msg}, metadata: {metadata}")
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def get_object_from_config(self, config: dict):
|
||||
gui_id = config.get("gui_id")
|
||||
obj = self.rpc_register.get_rpc_by_id(gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Object with gui_id {gui_id} not found")
|
||||
return obj
|
||||
|
||||
def run_rpc(self, obj, method, args, kwargs):
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
elif isinstance(res, dict):
|
||||
res = {key: self.serialize_object(val) for key, val in res.items()}
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
return res
|
||||
|
||||
def serialize_object(self, obj):
|
||||
if isinstance(obj, BECConnector):
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
}
|
||||
return obj
|
||||
|
||||
def emit_heartbeat(self):
|
||||
logger.trace(f"Emitting heartbeat for {self.gui_id}")
|
||||
try:
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
|
||||
expire=10,
|
||||
)
|
||||
except RedisError as exc:
|
||||
logger.error(f"Error while emitting heartbeat: {exc}")
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
|
||||
self.status = messages.BECStatus.IDLE
|
||||
self._heartbeat_timer.stop()
|
||||
self.emit_heartbeat()
|
||||
self.gui.close()
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
class SimpleFileLikeFromLogOutputFunc:
|
||||
@@ -37,120 +193,37 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
class GUIServer:
|
||||
"""
|
||||
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
|
||||
"""
|
||||
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
|
||||
if config:
|
||||
try:
|
||||
config = json.loads(config)
|
||||
service_config = ServiceConfig(config=config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
service_config = ServiceConfig(config_path=config)
|
||||
else:
|
||||
# if no config is provided, use the default config
|
||||
service_config = ServiceConfig()
|
||||
|
||||
def __init__(self, args):
|
||||
self.config = args.config
|
||||
self.gui_id = args.id
|
||||
self.gui_class = args.gui_class
|
||||
self.gui_class_id = args.gui_class_id
|
||||
self.hide = args.hide
|
||||
self.app: QApplication | None = None
|
||||
self.launcher_window: LaunchWindow | None = None
|
||||
self.dispatcher: BECDispatcher | None = None
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the GUI server.
|
||||
"""
|
||||
bec_logger.level = bec_logger.LOGLEVEL.INFO
|
||||
if self.hide:
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
|
||||
self._run()
|
||||
|
||||
def _get_service_config(self) -> ServiceConfig:
|
||||
if self.config:
|
||||
try:
|
||||
config = json.loads(self.config)
|
||||
service_config = ServiceConfig(config=config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
service_config = ServiceConfig(config_path=config)
|
||||
else:
|
||||
# if no config is provided, use the default config
|
||||
service_config = ServiceConfig()
|
||||
return service_config
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
Run the GUI server.
|
||||
"""
|
||||
self.app = QApplication(sys.argv)
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
self.setup_bec_icon()
|
||||
|
||||
service_config = self._get_service_config()
|
||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
|
||||
|
||||
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
|
||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
if self.gui_class:
|
||||
# If the server is started with a specific gui class, we launch it.
|
||||
# This will automatically hide the launcher.
|
||||
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# Widgets should be all closed.
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||
widget.close()
|
||||
if self.app:
|
||||
self.app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
def setup_bec_icon(self):
|
||||
"""
|
||||
Set the BEC icon for the application
|
||||
"""
|
||||
if self.app is None:
|
||||
return
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
self.app.setWindowIcon(icon)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
token = os.getenv("BEC_GUI_TOKEN")
|
||||
server = BECWidgetsCLIServer(
|
||||
gui_id=gui_id, config=service_config, gui_class=gui_class, token=token
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for subprocesses that start a GUI server.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, default="test", help="The id of the server")
|
||||
@@ -159,23 +232,76 @@ def main():
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui_class_id",
|
||||
type=str,
|
||||
default="bec",
|
||||
help="The id of the gui class that is added to the QApplication",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config file or config string.")
|
||||
parser.add_argument("--hide", action="store_true", help="Hide on startup")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
server = GUIServer(args)
|
||||
server.start()
|
||||
bec_logger.level = bec_logger.LOGLEVEL.INFO
|
||||
if args.hide:
|
||||
# pylint: disable=protected-access
|
||||
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
|
||||
bec_logger._update_sinks()
|
||||
|
||||
if args.gui_class == "BECDockArea":
|
||||
gui_class = BECDockArea
|
||||
elif args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
else:
|
||||
print(
|
||||
"Please specify a valid gui_class to run. Use -h for help."
|
||||
"\n Starting with default gui_class BECFigure."
|
||||
)
|
||||
gui_class = BECDockArea
|
||||
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
||||
app = QApplication(sys.argv)
|
||||
# set close on last window, only if not under control of client ;
|
||||
# indeed, Qt considers a hidden window a closed window, so if all windows
|
||||
# are hidden by default it exits
|
||||
app.setQuitOnLastWindowClosed(not args.hide)
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
size=QSize(48, 48),
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
# store gui id within QApplication object, to make it available to all widgets
|
||||
app.gui_id = args.id
|
||||
|
||||
server = _start_server(args.id, gui_class, args.config)
|
||||
|
||||
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
|
||||
win.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
|
||||
RPCRegister().add_rpc(win)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
if not args.hide:
|
||||
win.show()
|
||||
|
||||
app.aboutToQuit.connect(server.shutdown)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
# first hide all top level windows
|
||||
# this is to discriminate the cases between "user clicks on [X]"
|
||||
# (which should be filtered, to not close -see BECDockArea-)
|
||||
# or "app is asked to close"
|
||||
for window in app.topLevelWidgets():
|
||||
window.hide() # so, we know we can exit because it is hidden
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# import sys
|
||||
|
||||
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
|
||||
main()
|
||||
|
||||
@@ -15,16 +15,12 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.figure import BECFigure
|
||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
@@ -41,24 +37,34 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"fig": self.figure,
|
||||
"dock": self.dock,
|
||||
"w1": self.w1,
|
||||
"w2": self.w2,
|
||||
"w3": self.w3,
|
||||
"w4": self.w4,
|
||||
"w5": self.w5,
|
||||
"w6": self.w6,
|
||||
"w7": self.w7,
|
||||
"w8": self.w8,
|
||||
"w9": self.w9,
|
||||
"w10": self.w10,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"wave": self.wf,
|
||||
"im": self.im,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
# "btn1": self.btn1,
|
||||
# "btn2": self.btn2,
|
||||
# "btn3": self.btn3,
|
||||
# "btn4": self.btn4,
|
||||
# "btn5": self.btn5,
|
||||
# "btn6": self.btn6,
|
||||
# "pb": self.pb,
|
||||
# "pi": self.pi,
|
||||
# "wf": self.wf,
|
||||
# "scatter": self.scatter,
|
||||
# "scatter_mi": self.scatter,
|
||||
# "mwf": self.mwf,
|
||||
"mm": self.mm,
|
||||
"mw": self.mw,
|
||||
"lm": self.lm,
|
||||
"btn1": self.btn1,
|
||||
"btn2": self.btn2,
|
||||
"btn3": self.btn3,
|
||||
"btn4": self.btn4,
|
||||
"btn5": self.btn5,
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -77,85 +83,126 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
first_tab_layout.addWidget(self.dock)
|
||||
tab_widget.addTab(first_tab, "Dock Area")
|
||||
|
||||
# third_tab = QWidget()
|
||||
# third_tab_layout = QVBoxLayout(third_tab)
|
||||
# self.lm = LayoutManagerWidget()
|
||||
# third_tab_layout.addWidget(self.lm)
|
||||
# tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
#
|
||||
# fourth_tab = QWidget()
|
||||
# fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
# self.pb = PlotBase()
|
||||
# self.pi = self.pb.plot_item
|
||||
# fourth_tab_layout.addWidget(self.pb)
|
||||
# tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
#
|
||||
# tab_widget.setCurrentIndex(3)
|
||||
#
|
||||
second_tab = QWidget()
|
||||
second_tab_layout = QVBoxLayout(second_tab)
|
||||
self.figure = BECFigure(parent=self, gui_id="figure")
|
||||
second_tab_layout.addWidget(self.figure)
|
||||
tab_widget.addTab(second_tab, "BEC Figure")
|
||||
|
||||
third_tab = QWidget()
|
||||
third_tab_layout = QVBoxLayout(third_tab)
|
||||
self.lm = LayoutManagerWidget()
|
||||
third_tab_layout.addWidget(self.lm)
|
||||
tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
|
||||
fourth_tab = QWidget()
|
||||
fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PltoBase")
|
||||
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
#
|
||||
# # Some buttons for layout testing
|
||||
# self.btn1 = QPushButton("Button 1")
|
||||
# self.btn2 = QPushButton("Button 2")
|
||||
# self.btn3 = QPushButton("Button 3")
|
||||
# self.btn4 = QPushButton("Button 4")
|
||||
# self.btn5 = QPushButton("Button 5")
|
||||
# self.btn6 = QPushButton("Button 6")
|
||||
#
|
||||
# fifth_tab = QWidget()
|
||||
# fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
# self.wf = Waveform()
|
||||
# fifth_tab_layout.addWidget(self.wf)
|
||||
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
# tab_widget.setCurrentIndex(4)
|
||||
#
|
||||
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)
|
||||
#
|
||||
# seventh_tab = QWidget()
|
||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
# self.scatter = ScatterWaveform()
|
||||
# self.scatter_mi = self.scatter.main_curve
|
||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
||||
# seventh_tab_layout.addWidget(self.scatter)
|
||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
# tab_widget.setCurrentIndex(6)
|
||||
#
|
||||
# eighth_tab = QWidget()
|
||||
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
# self.mm = MotorMap()
|
||||
# eighth_tab_layout.addWidget(self.mm)
|
||||
# tab_widget.addTab(eighth_tab, "Motor Map")
|
||||
# tab_widget.setCurrentIndex(7)
|
||||
#
|
||||
# ninth_tab = QWidget()
|
||||
# ninth_tab_layout = QVBoxLayout(ninth_tab)
|
||||
# self.mwf = MultiWaveform()
|
||||
# ninth_tab_layout.addWidget(self.mwf)
|
||||
# tab_widget.addTab(ninth_tab, "MultiWaveform")
|
||||
# tab_widget.setCurrentIndex(8)
|
||||
#
|
||||
# # add stuff to the new Waveform widget
|
||||
# self._init_waveform()
|
||||
#
|
||||
# self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_waveform(self):
|
||||
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
# init dock for testing
|
||||
self._init_dock()
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_figure(self):
|
||||
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
|
||||
self.w1.set(
|
||||
title="Standard Plot with sync device, custom labels - w1",
|
||||
x_label="Motor Position",
|
||||
y_label="Intensity (A.U.)",
|
||||
)
|
||||
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
|
||||
self.w3 = self.figure.image(
|
||||
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
|
||||
)
|
||||
self.w4 = self.figure.plot(
|
||||
x_name="samx",
|
||||
y_name="samy",
|
||||
z_name="bpm4i",
|
||||
color_map_z="magma",
|
||||
new=True,
|
||||
title="2D scatter plot - w4",
|
||||
row=0,
|
||||
col=3,
|
||||
)
|
||||
self.w5 = self.figure.plot(
|
||||
y_name="bpm4i",
|
||||
new=True,
|
||||
title="Best Effort Plot - w5",
|
||||
dap="GaussianModel",
|
||||
row=1,
|
||||
col=0,
|
||||
)
|
||||
self.w6 = self.figure.plot(
|
||||
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
|
||||
)
|
||||
self.w7 = self.figure.plot(
|
||||
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
|
||||
)
|
||||
self.w8 = self.figure.plot(
|
||||
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
|
||||
)
|
||||
self.w9 = self.figure.plot(
|
||||
x_name="timestamp",
|
||||
y_name="monitor_async",
|
||||
new=True,
|
||||
title="Async Plot - timestamp - w9",
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
self.w10 = self.figure.plot(
|
||||
x_name="index",
|
||||
y_name="monitor_async",
|
||||
new=True,
|
||||
title="Async Plot - index - w10",
|
||||
row=2,
|
||||
col=2,
|
||||
)
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.mm = self.d0.add_widget("BECMotorMapWidget")
|
||||
self.mm.change_motors("samx", "samy")
|
||||
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.im = self.d1.add_widget("BECImageWidget")
|
||||
self.im.image("waveform", "1d")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
|
||||
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.dock.close()
|
||||
self.figure.cleanup()
|
||||
self.figure.close()
|
||||
self.console.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
@@ -171,16 +218,17 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
apply_theme("dark")
|
||||
icon = material_icon("terminal", color="#434343", filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
win.resize(1500, 800)
|
||||
win.resize(1200, 800)
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -6,7 +6,7 @@ from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
|
||||
|
||||
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
|
||||
|
||||
class TicTacToeDialog(QDialog): # pragma: no cover
|
||||
|
||||
@@ -266,5 +266,3 @@ class CompactPopupWidget(QWidget):
|
||||
# to ensure proper resources cleanup
|
||||
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
||||
child.close()
|
||||
|
||||
super().closeEvent(event)
|
||||
@@ -96,55 +96,23 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
|
||||
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
|
||||
otherwise error display is left to the original exception hook
|
||||
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
|
||||
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
|
||||
useful to prevent function calls from already deleted objects.
|
||||
'raise_error' keyword argument can be passed with boolean value if the error should be raised
|
||||
after the error is displayed. This is useful to propagate the error to the caller but should be used
|
||||
with great care to avoid segfaults.
|
||||
|
||||
The keywords above are stored in a container which can be overridden by passing
|
||||
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
|
||||
This is useful to override the default behavior of the decorator for a specific function call.
|
||||
|
||||
"""
|
||||
_slot_params = {
|
||||
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
|
||||
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
|
||||
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
|
||||
}
|
||||
popup_error = bool(slot_kwargs.pop("popup_error", False))
|
||||
|
||||
def error_managed(method):
|
||||
@Slot(*slot_args, **slot_kwargs)
|
||||
@functools.wraps(method)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
_override_slot_params = kwargs.pop("_override_slot_params", {})
|
||||
_slot_params.update(_override_slot_params)
|
||||
try:
|
||||
if not _slot_params["verify_sender"] or len(args) == 0:
|
||||
return method(*args, **kwargs)
|
||||
|
||||
_instance = args[0]
|
||||
if not isinstance(_instance, QObject):
|
||||
return method(*args, **kwargs)
|
||||
sender = _instance.sender()
|
||||
if sender is None:
|
||||
logger.info(
|
||||
f"Sender is None for {method.__module__}.{method.__qualname__}, "
|
||||
"skipping method call."
|
||||
)
|
||||
return
|
||||
return method(*args, **kwargs)
|
||||
|
||||
except Exception:
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if _slot_params["popup_error"]:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=popup_error
|
||||
)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
if _slot_params["raise_error"]:
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
72
bec_widgets/qt_utils/expandable_frame.py
Normal file
72
bec_widgets/qt_utils/expandable_frame.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
COLLAPSED_ICON_NAME: str = "expand_all"
|
||||
|
||||
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_icon()
|
||||
self._title = QLabel(f"<b>{title}</b>")
|
||||
self._title_layout.addWidget(self._expansion_button)
|
||||
self._title_layout.addWidget(self._title)
|
||||
|
||||
self._contents = QWidget()
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_icon()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
return self._expanded
|
||||
|
||||
@expanded.setter
|
||||
def expanded(self, expanded: bool):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
|
||||
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
|
||||
else material_icon(
|
||||
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
@@ -22,10 +22,10 @@ class PaletteViewer(BECWidget, QWidget):
|
||||
"""
|
||||
|
||||
ICON_NAME = "palette"
|
||||
RPC = False
|
||||
|
||||
def __init__(self, *args, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, theme_update=True, **kwargs)
|
||||
super().__init__(*args, theme_update=True, **kwargs)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.setFixedSize(400, 600)
|
||||
layout = QVBoxLayout(self)
|
||||
dark_mode_button = DarkModeButton(self)
|
||||
@@ -2,10 +2,11 @@ import pyqtgraph as pg
|
||||
from qtpy.QtCore import Property
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
|
||||
class RoundedFrame(QFrame):
|
||||
class RoundedFrame(BECWidget, QFrame):
|
||||
"""
|
||||
A custom QFrame with rounded corners and optional theme updates.
|
||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||
@@ -16,12 +17,15 @@ class RoundedFrame(QFrame):
|
||||
parent=None,
|
||||
content_widget: QWidget = None,
|
||||
background_color: str = None,
|
||||
orientation: str = "horizontal",
|
||||
theme_update: bool = True,
|
||||
radius: int = 10,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
QFrame.__init__(self, parent)
|
||||
|
||||
self.background_color = background_color
|
||||
self.theme_update = theme_update if background_color is None else False
|
||||
self._radius = radius
|
||||
|
||||
# Apply rounded frame styling
|
||||
@@ -29,12 +33,8 @@ class RoundedFrame(QFrame):
|
||||
self.setObjectName("roundedFrame")
|
||||
|
||||
# Create a layout for the frame
|
||||
if orientation == "vertical":
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5)
|
||||
else:
|
||||
self.layout = QHBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
|
||||
self.layout = QHBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
|
||||
|
||||
# Add the content widget to the layout
|
||||
if content_widget:
|
||||
@@ -46,14 +46,14 @@ class RoundedFrame(QFrame):
|
||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the frame and its content if theme updates are enabled.
|
||||
"""
|
||||
if self.content_widget is not None and isinstance(
|
||||
self.content_widget, pg.GraphicsLayoutWidget
|
||||
):
|
||||
self.content_widget.setBackground(self.background_color)
|
||||
if not self.theme_update:
|
||||
return
|
||||
|
||||
# Update background color based on the theme
|
||||
if theme == "light":
|
||||
@@ -129,8 +129,8 @@ class ExampleApp(QWidget): # pragma: no cover
|
||||
plot2.plot_item = plot_item_2
|
||||
|
||||
# Wrap PlotWidgets in RoundedFrame
|
||||
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
|
||||
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
|
||||
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
|
||||
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(dark_button)
|
||||
@@ -1,10 +1,6 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtGui import QCloseEvent
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class SettingWidget(QWidget):
|
||||
@@ -41,15 +37,6 @@ class SettingWidget(QWidget):
|
||||
"""
|
||||
pass
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the settings widget.
|
||||
"""
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.cleanup()
|
||||
return super().closeEvent(event)
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""
|
||||
@@ -112,17 +99,8 @@ class SettingsDialog(QDialog):
|
||||
Accept the changes made in the settings widget and close the dialog.
|
||||
"""
|
||||
self.widget.accept_changes()
|
||||
self.cleanup()
|
||||
super().accept()
|
||||
|
||||
@SafeSlot()
|
||||
def reject(self):
|
||||
"""
|
||||
Reject the changes made in the settings widget and close the dialog.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().reject()
|
||||
|
||||
@SafeSlot()
|
||||
def apply_changes(self):
|
||||
"""
|
||||
@@ -136,10 +114,7 @@ class SettingsDialog(QDialog):
|
||||
"""
|
||||
self.button_box.close()
|
||||
self.button_box.deleteLater()
|
||||
self.widget.close()
|
||||
self.widget.deleteLater()
|
||||
|
||||
def closeEvent(self, event):
|
||||
logger.info("Closing settings dialog")
|
||||
self.cleanup()
|
||||
super().closeEvent(event)
|
||||
@@ -16,7 +16,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
|
||||
class SidePanel(QWidget):
|
||||
@@ -31,17 +31,16 @@ class SidePanel(QWidget):
|
||||
panel_max_width: int = 200,
|
||||
animation_duration: int = 200,
|
||||
animations_enabled: bool = True,
|
||||
show_toolbar: bool = True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setObjectName("SidePanel")
|
||||
|
||||
self._orientation = orientation
|
||||
self._panel_max_width = panel_max_width
|
||||
self._animation_duration = animation_duration
|
||||
self._animations_enabled = animations_enabled
|
||||
self._show_toolbar = show_toolbar
|
||||
|
||||
self._panel_width = 0
|
||||
self._panel_height = 0
|
||||
@@ -61,7 +60,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="vertical")
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -73,14 +72,13 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setMinimumWidth(5)
|
||||
self.stack_widget.setMaximumWidth(self._panel_max_width)
|
||||
|
||||
if self._orientation in ("left", "right"):
|
||||
if self._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
if self._orientation == "left":
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.addWidget(self.container)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
if self._orientation == "left":
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.insertWidget(0, self.container)
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
self.menu_anim = QPropertyAnimation(self, b"panel_width")
|
||||
@@ -92,7 +90,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self, target_widget=self, orientation="horizontal")
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -105,13 +103,11 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setMaximumHeight(self._panel_max_width)
|
||||
|
||||
if self._orientation == "top":
|
||||
if self._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.addWidget(self.container)
|
||||
if self._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
@@ -236,26 +232,16 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setCurrentIndex(idx)
|
||||
self.current_index = idx
|
||||
|
||||
def add_menu(
|
||||
self,
|
||||
widget: QWidget,
|
||||
action_id: str | None = None,
|
||||
icon_name: str | None = None,
|
||||
tooltip: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> int:
|
||||
def add_menu(self, action_id: str, icon_name: str, tooltip: str, widget: QWidget, title: str):
|
||||
"""
|
||||
Add a menu to the side panel.
|
||||
|
||||
Args:
|
||||
action_id(str): The ID of the action.
|
||||
icon_name(str): The name of the icon.
|
||||
tooltip(str): The tooltip for the action.
|
||||
widget(QWidget): The widget to add to the panel.
|
||||
action_id(str | None): The ID of the action. Optional if no toolbar action is needed.
|
||||
icon_name(str | None): The name of the icon. Optional if no toolbar action is needed.
|
||||
tooltip(str | None): The tooltip for the action. Optional if no toolbar action is needed.
|
||||
title(str | None): The title of the panel.
|
||||
|
||||
Returns:
|
||||
int: The index of the added panel, which can be used with show_panel() and switch_to().
|
||||
title(str): The title of the panel.
|
||||
"""
|
||||
# container_widget: top-level container for the stacked page
|
||||
container_widget = QWidget()
|
||||
@@ -263,10 +249,9 @@ class SidePanel(QWidget):
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.setSpacing(5)
|
||||
|
||||
if title is not None:
|
||||
title_label = QLabel(f"<b>{title}</b>")
|
||||
title_label.setStyleSheet("font-size: 16px;")
|
||||
container_layout.addWidget(title_label)
|
||||
title_label = QLabel(f"<b>{title}</b>")
|
||||
title_label.setStyleSheet("font-size: 16px;")
|
||||
container_layout.addWidget(title_label)
|
||||
|
||||
# Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
|
||||
scroll_area = QScrollArea()
|
||||
@@ -286,35 +271,32 @@ class SidePanel(QWidget):
|
||||
index = self.stack_widget.count()
|
||||
self.stack_widget.addWidget(container_widget)
|
||||
|
||||
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
|
||||
if action_id is not None and icon_name is not None and tooltip is not None:
|
||||
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
|
||||
self.toolbar.add_action(action_id, action, target_widget=self)
|
||||
# Add an action to the toolbar
|
||||
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
|
||||
self.toolbar.add_action(action_id, action, target_widget=self)
|
||||
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
return
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
return
|
||||
|
||||
if checked:
|
||||
if self.current_action and self.current_action != action.action:
|
||||
self.switching_actions = True
|
||||
self.current_action.setChecked(False)
|
||||
self.switching_actions = False
|
||||
if checked:
|
||||
if self.current_action and self.current_action != action.action:
|
||||
self.switching_actions = True
|
||||
self.current_action.setChecked(False)
|
||||
self.switching_actions = False
|
||||
|
||||
self.current_action = action.action
|
||||
self.current_action = action.action
|
||||
|
||||
if not self.panel_visible:
|
||||
self.show_panel(index)
|
||||
else:
|
||||
self.switch_to(index)
|
||||
if not self.panel_visible:
|
||||
self.show_panel(index)
|
||||
else:
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
self.switch_to(index)
|
||||
else:
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
|
||||
return index
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
|
||||
|
||||
############################################
|
||||
@@ -335,64 +317,49 @@ class ExampleApp(QMainWindow): # pragma: no cover
|
||||
self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
|
||||
self.layout.addWidget(self.side_panel)
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
|
||||
self.plot = Waveform()
|
||||
self.plot = BECWaveformWidget()
|
||||
self.layout.addWidget(self.plot)
|
||||
|
||||
self.add_side_menus()
|
||||
|
||||
def add_side_menus(self):
|
||||
# Example 1: With action, icon, and tooltip
|
||||
widget1 = QWidget()
|
||||
layout1 = QVBoxLayout(widget1)
|
||||
for i in range(15):
|
||||
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget1,
|
||||
action_id="widget1",
|
||||
icon_name="counter_1",
|
||||
tooltip="Show Widget 1",
|
||||
widget=widget1,
|
||||
title="Widget 1 Panel",
|
||||
)
|
||||
|
||||
# Example 2: With action, icon, and tooltip
|
||||
widget2 = QWidget()
|
||||
layout2 = QVBoxLayout(widget2)
|
||||
layout2.addWidget(QLabel("Short widget 2 content"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget2,
|
||||
action_id="widget2",
|
||||
icon_name="counter_2",
|
||||
tooltip="Show Widget 2",
|
||||
widget=widget2,
|
||||
title="Widget 2 Panel",
|
||||
)
|
||||
|
||||
# Example 3: With action, icon, and tooltip
|
||||
widget3 = QWidget()
|
||||
layout3 = QVBoxLayout(widget3)
|
||||
for i in range(10):
|
||||
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget3,
|
||||
action_id="widget3",
|
||||
icon_name="counter_3",
|
||||
tooltip="Show Widget 3",
|
||||
widget=widget3,
|
||||
title="Widget 3 Panel",
|
||||
)
|
||||
|
||||
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
|
||||
widget4 = QWidget()
|
||||
layout4 = QVBoxLayout(widget4)
|
||||
layout4.addWidget(QLabel("This panel has no toolbar button"))
|
||||
layout4.addWidget(QLabel("It can only be shown programmatically"))
|
||||
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
|
||||
|
||||
# Example of how to show the hidden panel programmatically after 3 seconds
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
@@ -7,7 +7,6 @@ from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Literal, Tuple
|
||||
|
||||
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
|
||||
@@ -32,8 +31,6 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# Ensure that icons are shown in menus (especially on macOS)
|
||||
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
||||
|
||||
@@ -121,7 +118,7 @@ class IconAction(ToolBarAction):
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
icon = QIcon()
|
||||
icon.addFile(self.icon_path, size=QSize(20, 20))
|
||||
self.action = QAction(icon=icon, text=self.tooltip, parent=target)
|
||||
self.action = QAction(icon, self.tooltip, target)
|
||||
self.action.setCheckable(self.checkable)
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
@@ -131,7 +128,7 @@ class QtIconAction(ToolBarAction):
|
||||
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 = QAction(self.icon, self.tooltip, parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
@@ -176,11 +173,7 @@ class MaterialIconAction(ToolBarAction):
|
||||
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 = QAction(self.icon, self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
@@ -219,12 +212,12 @@ class DeviceSelectionAction(ToolBarAction):
|
||||
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget(parent=target)
|
||||
widget = QWidget()
|
||||
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)
|
||||
label = QLabel(f"{self.label}")
|
||||
layout.addWidget(label)
|
||||
if self.device_combobox is not None:
|
||||
layout.addWidget(self.device_combobox)
|
||||
@@ -286,63 +279,31 @@ class SwitchableToolBarAction(ToolBarAction):
|
||||
self.main_button.setToolTip(default_action.tooltip)
|
||||
self.main_button.clicked.connect(self._trigger_current_action)
|
||||
menu = QMenu(self.main_button)
|
||||
self.menu_actions = {}
|
||||
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 = QAction(action_obj.get_icon(), action_obj.tooltip, 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.menu_actions[key] = menu_action
|
||||
self.main_button.setMenu(menu)
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
for k, menu_act in self.menu_actions.items():
|
||||
menu_act.setChecked(k == key)
|
||||
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.
|
||||
"""
|
||||
self.main_button.blockSignals(block)
|
||||
for action in self.actions.values():
|
||||
action.action.blockSignals(block)
|
||||
|
||||
def set_state_all(self, state: bool):
|
||||
"""
|
||||
Uncheck all actions in the toolbar.
|
||||
"""
|
||||
for action in self.actions.values():
|
||||
action.action.setChecked(state)
|
||||
self.main_button.setChecked(state)
|
||||
|
||||
def get_icon(self) -> QIcon:
|
||||
return self.actions[self.current_key].get_icon()
|
||||
@@ -357,18 +318,11 @@ class WidgetAction(ToolBarAction):
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
widget: QWidget = None,
|
||||
adjust_size: bool = True,
|
||||
parent=None,
|
||||
):
|
||||
def __init__(self, label: str | None = None, widget: QWidget = None, 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):
|
||||
"""
|
||||
@@ -378,18 +332,18 @@ class WidgetAction(ToolBarAction):
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.container = QWidget(parent=target)
|
||||
self.container = QWidget()
|
||||
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 = QLabel(f"{self.label}")
|
||||
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:
|
||||
if isinstance(self.widget, QComboBox):
|
||||
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
@@ -446,7 +400,7 @@ class ExpandableMenuAction(ToolBarAction):
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_id, action in self.actions.items():
|
||||
sub_action = QAction(text=action.tooltip, parent=target)
|
||||
sub_action = QAction(action.tooltip, target)
|
||||
sub_action.setIconVisibleInMenu(True)
|
||||
if action.icon_path:
|
||||
icon = QIcon()
|
||||
@@ -530,7 +484,7 @@ class ModularToolBar(QToolBar):
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
||||
background_color: str = "rgba(0, 0, 0, 0)",
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
super().__init__(parent)
|
||||
|
||||
self.widgets = defaultdict(dict)
|
||||
self.background_color = background_color
|
||||
@@ -709,85 +663,6 @@ class ModularToolBar(QToolBar):
|
||||
self.bundles[bundle_id].append(action_id)
|
||||
self.update_separators()
|
||||
|
||||
def remove_action(self, action_id: str):
|
||||
"""
|
||||
Completely remove a single action from the toolbar.
|
||||
|
||||
The method takes care of both standalone actions and actions that are
|
||||
part of an existing bundle.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier for the action.
|
||||
"""
|
||||
if action_id not in self.widgets:
|
||||
raise ValueError(f"Action with ID '{action_id}' does not exist.")
|
||||
|
||||
# Identify potential bundle membership
|
||||
parent_bundle = None
|
||||
for b_id, a_ids in self.bundles.items():
|
||||
if action_id in a_ids:
|
||||
parent_bundle = b_id
|
||||
break
|
||||
|
||||
# 1. Remove the QAction from the QToolBar and delete it
|
||||
tool_action = self.widgets.pop(action_id)
|
||||
if hasattr(tool_action, "action") and tool_action.action is not None:
|
||||
self.removeAction(tool_action.action)
|
||||
tool_action.action.deleteLater()
|
||||
|
||||
# 2. Clean bundle bookkeeping if the action belonged to one
|
||||
if parent_bundle:
|
||||
self.bundles[parent_bundle].remove(action_id)
|
||||
# If the bundle becomes empty, get rid of the bundle entry as well
|
||||
if not self.bundles[parent_bundle]:
|
||||
self.remove_bundle(parent_bundle)
|
||||
|
||||
# 3. Remove from the ordering list
|
||||
self.toolbar_items = [
|
||||
item
|
||||
for item in self.toolbar_items
|
||||
if not (item[0] == "action" and item[1] == action_id)
|
||||
]
|
||||
|
||||
self.update_separators()
|
||||
|
||||
def remove_bundle(self, bundle_id: str):
|
||||
"""
|
||||
Remove an entire bundle (and all of its actions) from the toolbar.
|
||||
|
||||
Args:
|
||||
bundle_id (str): Unique identifier for the bundle.
|
||||
"""
|
||||
if bundle_id not in self.bundles:
|
||||
raise ValueError(f"Bundle '{bundle_id}' does not exist.")
|
||||
|
||||
# Remove every action belonging to this bundle
|
||||
for action_id in list(self.bundles[bundle_id]): # copy the list
|
||||
if action_id in self.widgets:
|
||||
tool_action = self.widgets.pop(action_id)
|
||||
if hasattr(tool_action, "action") and tool_action.action is not None:
|
||||
self.removeAction(tool_action.action)
|
||||
tool_action.action.deleteLater()
|
||||
|
||||
# Drop the bundle entry
|
||||
self.bundles.pop(bundle_id, None)
|
||||
|
||||
# Remove bundle entry and its preceding separator (if any) from the ordering list
|
||||
cleaned_items = []
|
||||
skip_next_separator = False
|
||||
for item_type, ident in self.toolbar_items:
|
||||
if item_type == "bundle" and ident == bundle_id:
|
||||
# mark to skip one following separator if present
|
||||
skip_next_separator = True
|
||||
continue
|
||||
if skip_next_separator and item_type == "separator":
|
||||
skip_next_separator = False
|
||||
continue
|
||||
cleaned_items.append((item_type, ident))
|
||||
self.toolbar_items = cleaned_items
|
||||
|
||||
self.update_separators()
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""
|
||||
Overrides the context menu event to show toolbar actions with checkboxes and icons.
|
||||
@@ -946,13 +821,13 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
# For theme testing
|
||||
|
||||
self.dark_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dark_button = DarkModeButton(toolbar=True)
|
||||
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
|
||||
self.toolbar.add_action("dark_mode", dark_mode_action, self)
|
||||
|
||||
def add_bundles(self):
|
||||
home_action = MaterialIconAction(
|
||||
icon_name="home", tooltip="Home", checkable=False, parent=self
|
||||
icon_name="home", tooltip="Home", checkable=True, parent=self
|
||||
)
|
||||
settings_action = MaterialIconAction(
|
||||
icon_name="settings", tooltip="Settings", checkable=True, parent=self
|
||||
@@ -969,7 +844,6 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
],
|
||||
)
|
||||
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
|
||||
home_action.action.triggered.connect(lambda: self.switchable_action.set_state_all(False))
|
||||
|
||||
search_action = MaterialIconAction(
|
||||
icon_name="search", tooltip="Search", checkable=False, parent=self
|
||||
@@ -1023,20 +897,20 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
def add_switchable_button_checkable(self):
|
||||
action1 = MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Action 1", checkable=True, parent=self
|
||||
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
|
||||
)
|
||||
action2 = MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Action 2", checkable=True, filled=True, parent=self
|
||||
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
|
||||
)
|
||||
|
||||
self.switchable_action = SwitchableToolBarAction(
|
||||
switchable_action = SwitchableToolBarAction(
|
||||
actions={"action1": action1, "action2": action2},
|
||||
initial_action="action1",
|
||||
tooltip="Switchable Action",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action("switchable_action", self.switchable_action, self)
|
||||
self.toolbar.add_action("switchable_action", switchable_action, self)
|
||||
|
||||
action1.action.toggled.connect(
|
||||
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
|
||||
@@ -1057,20 +931,16 @@ class MainWindow(QMainWindow): # pragma: no cover
|
||||
actions={"action1": action1, "action2": action2},
|
||||
initial_action="action1",
|
||||
tooltip="Switchable Action",
|
||||
checkable=False,
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
|
||||
|
||||
action1.action.triggered.connect(
|
||||
lambda checked: self.test_label.setText(
|
||||
f"Action 1 (non-checkable) triggered, checked = {checked}"
|
||||
)
|
||||
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
|
||||
)
|
||||
action2.action.triggered.connect(
|
||||
lambda checked: self.test_label.setText(
|
||||
f"Action 2 (non-checkable) triggered, checked = {checked}"
|
||||
)
|
||||
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
|
||||
)
|
||||
switchable_action.actions["action1"].action.setChecked(True)
|
||||
|
||||
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": {"user device"},
|
||||
"deviceTags": ["user device"],
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
@@ -89,28 +89,16 @@ class FakePositioner(BECPositioner):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": {"user motors"},
|
||||
"deviceTags": ["user motors"],
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"obj_name": self.name,
|
||||
}, # hinted
|
||||
"setpoint": {
|
||||
"kind_str": "normal",
|
||||
"component_name": "setpoint",
|
||||
"obj_name": f"{self.name}_setpoint",
|
||||
}, # normal
|
||||
"velocity": {
|
||||
"kind_str": "config",
|
||||
"component_name": "velocity",
|
||||
"obj_name": f"{self.name}_velocity",
|
||||
}, # config
|
||||
"readback": {"kind_str": "5"}, # hinted
|
||||
"setpoint": {"kind_str": "1"}, # normal
|
||||
"velocity": {"kind_str": "2"}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
@@ -196,8 +184,8 @@ class FakePositioner(BECPositioner):
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
def __init__(self, name="test", limits=None, read_value=1.0):
|
||||
super().__init__(name, limits, read_value)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
@@ -212,49 +200,10 @@ class DMMock:
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
def add_devives(self, devices: list):
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -3,29 +3,25 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
else:
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
@@ -43,7 +39,8 @@ class ConnectionConfig(BaseModel):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
|
||||
v = f"{widget_class}_{str(time.time())}"
|
||||
return v
|
||||
return v
|
||||
|
||||
|
||||
@@ -78,52 +75,15 @@ class BECConnector:
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
object_name: str | None = None,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
root_widget: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
BECConnector mixin class to handle BEC client and device manager.
|
||||
|
||||
Args:
|
||||
client(BECClient, optional): The BEC client.
|
||||
config(ConnectionConfig, optional): The connection configuration with specific gui id.
|
||||
gui_id(str, optional): The GUI ID.
|
||||
object_name(str, optional): The object name.
|
||||
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
|
||||
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
**kwargs:
|
||||
"""
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
object_name = object_name or kwargs.pop("objectName", None)
|
||||
# Ensure the parent is always the first argument for QObject
|
||||
parent = kwargs.pop("parent", None)
|
||||
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
assert isinstance(
|
||||
self, QObject
|
||||
), "BECConnector must be used with a QObject or any qt related class."
|
||||
|
||||
# flag to check if the object was destroyed and its cleanup was called
|
||||
self._destroyed = False
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
|
||||
self.rpc_register = RPCRegister()
|
||||
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@SafeSlot()
|
||||
@pyqtSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
@@ -143,26 +103,17 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||
# the gui_id has to be unique, and may no longer be.
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id: str = gui_id # Keep namespace in sync
|
||||
self.gui_id = gui_id
|
||||
else:
|
||||
self.gui_id: str = self.config.gui_id # type: ignore
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
if object_name is not None:
|
||||
self.setObjectName(object_name)
|
||||
|
||||
# 1) If no objectName is set, set the initial name
|
||||
if not self.objectName():
|
||||
self.setObjectName(self.__class__.__name__)
|
||||
self.object_name = self.objectName()
|
||||
|
||||
# 2) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self.setParent(parent)
|
||||
if isinstance(self.parent(), QObject) and hasattr(self, "cleanup"):
|
||||
self.parent().destroyed.connect(self._run_cleanup_on_deleted_parent)
|
||||
# register widget to rpc register
|
||||
# be careful: when registering, and the object is not a BECWidget,
|
||||
# cleanup has to be called manually since there is no 'closeEvent'
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
# Error popups
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
@@ -171,119 +122,7 @@ class BECConnector:
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
self.root_widget = root_widget
|
||||
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
@property
|
||||
def parent_id(self) -> str | None:
|
||||
try:
|
||||
if self.root_widget:
|
||||
return None
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
return connector_parent.gui_id if connector_parent else None
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
@SafeSlot()
|
||||
def _run_cleanup_on_deleted_parent(self) -> None:
|
||||
"""
|
||||
Run cleanup on the deleted parent.
|
||||
This method is called when the parent is deleted.
|
||||
"""
|
||||
if not hasattr(self, "cleanup"):
|
||||
return
|
||||
try:
|
||||
if not self._destroyed:
|
||||
self.cleanup()
|
||||
self._destroyed = True
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.info(
|
||||
"Failed to run cleanup on deleted parent. "
|
||||
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
|
||||
)
|
||||
|
||||
def change_object_name(self, name: str) -> None:
|
||||
"""
|
||||
Change the object name of the widget. Unregister old name and register the new one.
|
||||
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
def _update_object_name(self) -> None:
|
||||
"""
|
||||
Enforce a unique object name among siblings and register the object for RPC.
|
||||
This method is called through a single shot timer kicked off in the constructor.
|
||||
"""
|
||||
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
Enforce that this BECConnector has a unique objectName among its siblings.
|
||||
|
||||
Sibling logic:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.processEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
# We have a parent => only compare with siblings under that parent
|
||||
siblings = parent_bec.findChildren(BECConnector)
|
||||
else:
|
||||
# No parent => treat all top-level BECConnectors as siblings
|
||||
# 1) Gather all BECConnectors from QApplication
|
||||
all_widgets = QApplication.allWidgets()
|
||||
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
|
||||
# 2) "Top-level" means closest BECConnector parent is None
|
||||
top_level_bec = [
|
||||
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
|
||||
]
|
||||
# 3) We are among these top-level siblings
|
||||
siblings = top_level_bec
|
||||
|
||||
# Collect used names among siblings
|
||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||
|
||||
base_name = self.object_name
|
||||
if base_name not in used_names:
|
||||
# Name is already unique among siblings
|
||||
return
|
||||
|
||||
# Need a suffix to avoid collision
|
||||
counter = 0
|
||||
while True:
|
||||
trial_name = f"{base_name}_{counter}"
|
||||
if trial_name not in used_names:
|
||||
self.setObjectName(trial_name)
|
||||
self.object_name = trial_name
|
||||
break
|
||||
counter += 1
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def setObjectName(self, name: str) -> None:
|
||||
"""
|
||||
Set the object name of the widget.
|
||||
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
super().setObjectName(name)
|
||||
self.object_name = name
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
self.rpc_register.broadcast()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
@@ -356,7 +195,6 @@ class BECConnector:
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
@@ -369,12 +207,11 @@ class BECConnector:
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self._set_gui_id(gui_id)
|
||||
self.set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
@@ -411,8 +248,8 @@ class BECConnector:
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
# @SafeSlot(str)
|
||||
def _set_gui_id(self, gui_id: str) -> None:
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
|
||||
@@ -443,7 +280,7 @@ class BECConnector:
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@SafeSlot(ConnectionConfig) # TODO can be also dict
|
||||
@pyqtSlot(ConnectionConfig) # TODO can be also dict
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
@@ -451,26 +288,9 @@ class BECConnector:
|
||||
Args:
|
||||
config (ConnectionConfig | dict): Configuration settings.
|
||||
"""
|
||||
gui_id = getattr(config, "gui_id", None)
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
self.config = config
|
||||
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
|
||||
self.config.gui_id = gui_id
|
||||
|
||||
def remove(self):
|
||||
"""Cleanup the BECConnector"""
|
||||
# If the widget is attached to a dock, remove it from the dock.
|
||||
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
|
||||
if self._parent_dock is not None:
|
||||
self._parent_dock.delete(self.object_name)
|
||||
# If the widget is from Qt, trigger its close method.
|
||||
elif hasattr(self, "close"):
|
||||
self.close()
|
||||
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
|
||||
@@ -10,15 +10,13 @@ from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
@@ -78,7 +76,7 @@ def list_editable_packages() -> set[str]:
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
def patch_designer(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
@@ -119,7 +117,7 @@ def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
@@ -147,24 +145,15 @@ def set_plugin_environment_variable(plugin_paths):
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def open_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
def main(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
|
||||
|
||||
plugin_paths = find_plugin_paths(base_dir)
|
||||
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
|
||||
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
|
||||
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer(cmd_args)
|
||||
|
||||
|
||||
def main():
|
||||
open_designer(sys.argv[1:])
|
||||
patch_designer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import random
|
||||
import string
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -15,52 +12,28 @@ from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
|
||||
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb: Callable, cb_info: dict | None = None):
|
||||
"""
|
||||
Initialize the QtThreadSafeCallback.
|
||||
|
||||
Args:
|
||||
cb (Callable): The callback function to be wrapped.
|
||||
cb_info (dict, optional): Additional information about the callback. Defaults to None.
|
||||
"""
|
||||
def __init__(self, cb):
|
||||
super().__init__()
|
||||
self.cb_info = cb_info
|
||||
|
||||
self.cb = cb
|
||||
self.cb_ref = louie.saferef.safe_ref(cb)
|
||||
self.cb_signal.connect(self.cb)
|
||||
self.topics = set()
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, QtThreadSafeCallback):
|
||||
return False
|
||||
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
|
||||
return id(self.cb)
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
if self.cb_ref() is None:
|
||||
# callback has been deleted
|
||||
return
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
@@ -100,29 +73,18 @@ class BECDispatcher:
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
client: BECClient
|
||||
cli_server: RPCServer | None = None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
client=None,
|
||||
config: str | ServiceConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
def __new__(cls, client=None, config: str = None, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BECDispatcher, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
|
||||
def __init__(self, client=None, config: str | ServiceConfig = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self._slots = collections.defaultdict(set)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
@@ -145,26 +107,18 @@ class BECDispatcher:
|
||||
except redis.exceptions.ConnectionError:
|
||||
logger.warning("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
register_serializer_extension()
|
||||
|
||||
logger.success("Initialized BECDispatcher")
|
||||
|
||||
self.start_cli_server(gui_id=gui_id)
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance of the BECDispatcher.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
|
||||
cb_info: dict | None = None,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
@@ -172,40 +126,34 @@ class BECDispatcher:
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
"""
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
if qt_slot not in self._registered_slots:
|
||||
self._registered_slots[qt_slot] = qt_slot
|
||||
qt_slot = self._registered_slots[qt_slot]
|
||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
self._slots[slot].update(set(topics_str))
|
||||
|
||||
def disconnect_slot(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
):
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
"""
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# but the slot we receive here is the original callable
|
||||
for connected_slot in self._registered_slots.values():
|
||||
for connected_slot in self._slots:
|
||||
if connected_slot.cb == slot:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
|
||||
if not self._registered_slots[connected_slot].topics:
|
||||
del self._registered_slots[connected_slot]
|
||||
self._slots[connected_slot].difference_update(set(topics_str))
|
||||
if not self._slots[connected_slot]:
|
||||
del self._slots[connected_slot]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
@@ -216,16 +164,11 @@ class BECDispatcher:
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
|
||||
remove_slots = []
|
||||
for connected_slot in self._registered_slots.values():
|
||||
connected_slot.topics.difference_update(set(topics_str))
|
||||
|
||||
if not connected_slot.topics:
|
||||
remove_slots.append(connected_slot)
|
||||
|
||||
for connected_slot in remove_slots:
|
||||
self._registered_slots.pop(connected_slot, None)
|
||||
for slot in list(self._slots.keys()):
|
||||
slot_topics = self._slots[slot]
|
||||
slot_topics.difference_update(set(topics_str))
|
||||
if not slot_topics:
|
||||
del self._slots[slot]
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
@@ -235,49 +178,4 @@ class BECDispatcher:
|
||||
*args: Arbitrary positional arguments
|
||||
**kwargs: Arbitrary keyword arguments
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
|
||||
def start_cli_server(self, gui_id: str | None = None):
|
||||
"""
|
||||
Start the CLI server.
|
||||
|
||||
Args:
|
||||
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
if gui_id is None:
|
||||
gui_id = self.generate_unique_identifier()
|
||||
|
||||
if not self.client.started:
|
||||
logger.error("Cannot start CLI server without a running client")
|
||||
return
|
||||
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
|
||||
logger.success(f"Started CLI server with gui_id: {gui_id}")
|
||||
|
||||
def stop_cli_server(self):
|
||||
"""
|
||||
Stop the CLI server.
|
||||
"""
|
||||
if self.cli_server is None:
|
||||
logger.error("Cannot stop CLI server without starting it first")
|
||||
return
|
||||
self.cli_server.shutdown()
|
||||
self.cli_server = None
|
||||
logger.success("Stopped CLI server")
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_identifier(length: int = 4) -> str:
|
||||
"""
|
||||
Generate a unique identifier for the application.
|
||||
|
||||
Args:
|
||||
length: The length of the identifier. Defaults to 4.
|
||||
|
||||
Returns:
|
||||
str: The unique identifier.
|
||||
"""
|
||||
allowed_chars = string.ascii_lowercase + string.digits
|
||||
return "".join(random.choices(allowed_chars, k=length))
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import pkgutil
|
||||
import traceback
|
||||
from importlib import util as importlib_util
|
||||
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
||||
from types import ModuleType
|
||||
from typing import Generator
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
"""Return specs for all submodules of the given module."""
|
||||
return tuple(
|
||||
module_info.module_finder.find_spec(module_info.name)
|
||||
for module_info in pkgutil.iter_modules(module.__path__)
|
||||
if isinstance(module_info.module_finder, FileFinder)
|
||||
)
|
||||
|
||||
|
||||
def _loaded_submodules_from_specs(
|
||||
submodule_specs: tuple[ModuleSpec | None, ...],
|
||||
) -> Generator[ModuleType, None, None]:
|
||||
"""Load all submodules from the given specs."""
|
||||
for submodule in (
|
||||
importlib_util.module_from_spec(spec) for spec in submodule_specs if spec is not None
|
||||
):
|
||||
assert isinstance(
|
||||
submodule.__loader__, SourceFileLoader
|
||||
), "Module found from FileFinder should have SourceFileLoader!"
|
||||
try:
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
|
||||
)
|
||||
yield submodule
|
||||
|
||||
|
||||
def _submodule_by_name(module: ModuleType, name: str):
|
||||
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
|
||||
if submod.__name__ == name:
|
||||
return submod
|
||||
return None
|
||||
|
||||
|
||||
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
||||
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
||||
|
||||
classes = inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget,
|
||||
)
|
||||
return BECClassContainer(
|
||||
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
|
||||
for k, v in classes
|
||||
)
|
||||
|
||||
|
||||
def _all_widgets_from_all_submods(module) -> BECClassContainer:
|
||||
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
|
||||
widgets = _get_widgets_from_module(module)
|
||||
if not hasattr(module, "__path__"):
|
||||
return widgets
|
||||
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
|
||||
widgets += _all_widgets_from_all_submods(submod)
|
||||
return widgets
|
||||
|
||||
|
||||
def user_widget_plugin() -> ModuleType | None:
|
||||
plugins = importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
|
||||
return None if len(plugins) == 0 else tuple(plugins)[0].load()
|
||||
|
||||
|
||||
def get_plugin_client_module() -> ModuleType | None:
|
||||
"""If there is a plugin repository installed, return the client module."""
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> BECClassContainer:
|
||||
"""If there is a plugin repository installed, load all widgets from it."""
|
||||
if plugin := user_widget_plugin():
|
||||
return _all_widgets_from_all_submods(plugin)
|
||||
else:
|
||||
return BECClassContainer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
client = get_plugin_client_module()
|
||||
print(get_all_plugin_widgets())
|
||||
...
|
||||
@@ -1,13 +1,13 @@
|
||||
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class BECSignalProxy(SignalProxy):
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -23,16 +17,13 @@ class BECWidget(BECConnector):
|
||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
gui_id: str = None,
|
||||
theme_update: bool = False,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -52,12 +43,11 @@ class BECWidget(BECConnector):
|
||||
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
|
||||
widget's apply_theme method will be called when the theme changes.
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
if not isinstance(self, QWidget):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Set the theme to auto if it is not set yet
|
||||
app = QApplication.instance()
|
||||
if not hasattr(app, "theme"):
|
||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
||||
@@ -98,16 +88,10 @@ class BECWidget(BECConnector):
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
try:
|
||||
if not self._destroyed:
|
||||
self.cleanup()
|
||||
self._destroyed = True
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -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)
|
||||
@@ -11,19 +11,16 @@ from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
return "dark"
|
||||
else:
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
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:
|
||||
|
||||
@@ -1,54 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Type
|
||||
import itertools
|
||||
from typing import Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
|
||||
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
|
||||
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
|
||||
# or alternatively raise an error that it can't be added again ( just raise an error)
|
||||
# 2. Dock names in between docks should also be unique
|
||||
|
||||
@staticmethod
|
||||
def has_name_valid_chars(name: str) -> bool:
|
||||
"""Check if the name is valid.
|
||||
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
|
||||
"""
|
||||
Generate a unique widget ID.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
container(dict): The container of widgets.
|
||||
prefix(str): The prefix of the widget ID.
|
||||
|
||||
Returns:
|
||||
bool: True if the name is valid, False otherwise.
|
||||
widget_id(str): The unique widget ID.
|
||||
"""
|
||||
if not name or len(name) > 256:
|
||||
return False # Don't accept empty names or names longer than 256 characters
|
||||
check_value = name.replace("_", "").replace("-", "")
|
||||
if not check_value.isalnum() or not check_value.isascii():
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
|
||||
"""Generate a unique ID.
|
||||
|
||||
Args:
|
||||
name(str): The name of the widget.
|
||||
Returns:
|
||||
tuple (str): The unique name
|
||||
"""
|
||||
if list_of_names is None:
|
||||
list_of_names = []
|
||||
ii = 0
|
||||
while ii < 1000: # 1000 is arbritrary!
|
||||
name_candidate = f"{name}_{ii}"
|
||||
if name_candidate not in list_of_names:
|
||||
return name_candidate
|
||||
ii += 1
|
||||
raise ValueError("Could not generate a unique name after within 1000 attempts.")
|
||||
existing_ids = set(container.keys())
|
||||
for i in itertools.count(1):
|
||||
widget_id = f"{prefix}_{i}"
|
||||
if widget_id not in existing_ids:
|
||||
return widget_id
|
||||
|
||||
@staticmethod
|
||||
def find_first_widget_by_class(
|
||||
@@ -72,36 +48,3 @@ class WidgetContainerUtils:
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"No widget of class {widget_class} found.")
|
||||
|
||||
@staticmethod
|
||||
def name_is_protected(name: str, container: Any = None) -> bool:
|
||||
"""
|
||||
Check if the name is not protected.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
|
||||
Returns:
|
||||
bool: True if the name is not protected, False otherwise.
|
||||
"""
|
||||
if container is None:
|
||||
container = BECGuiClient
|
||||
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
|
||||
return name in gui_client_methods
|
||||
|
||||
@staticmethod
|
||||
def raise_for_invalid_name(name: str, container: Any = None) -> None:
|
||||
"""
|
||||
Check if the name is valid. If not, raise a ValueError.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
Raises:
|
||||
ValueError: If the name is not valid.
|
||||
"""
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(
|
||||
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
|
||||
)
|
||||
if WidgetContainerUtils.name_is_protected(name, container):
|
||||
raise ValueError(f"Name '{name}' is protected. Please choose another name.")
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
@@ -34,21 +31,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 +45,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 non‑negative
|
||||
|
||||
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 +82,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,33 +192,20 @@ 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)
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
|
||||
# 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)
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
|
||||
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
|
||||
"""
|
||||
Finds the nearest data points to the given x and y coordinates.
|
||||
|
||||
Args:
|
||||
x(float): The x-coordinate of the mouse cursor
|
||||
y(float): The y-coordinate of the mouse cursor
|
||||
x: The x-coordinate of the mouse cursor
|
||||
y: The y-coordinate of the mouse cursor
|
||||
|
||||
Returns:
|
||||
tuple: x and y values snapped to the nearest data
|
||||
@@ -312,10 +235,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
|
||||
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 +305,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 +315,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
|
||||
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 +358,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 +369,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
|
||||
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,20 +418,9 @@ 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})"
|
||||
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}"
|
||||
break
|
||||
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(text)
|
||||
self.coord_label.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
|
||||
self.coord_label.setPos(x, y)
|
||||
self.coord_label.setVisible(True)
|
||||
|
||||
@@ -534,12 +436,6 @@ 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
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
self.plot_item.removeItem(self.coord_label)
|
||||
|
||||
@@ -17,24 +17,12 @@ class EntryValidator:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
|
||||
# Build list of available signal entries from device._info['signals']
|
||||
signals_dict = getattr(device, "_info", {}).get("signals", {})
|
||||
available_entries = [
|
||||
sig.get("obj_name") for sig in signals_dict.values() if sig.get("obj_name")
|
||||
]
|
||||
|
||||
# If no signals are found, means device is a signal, use the device name as the entry
|
||||
if not available_entries:
|
||||
available_entries = [name]
|
||||
description = device.describe()
|
||||
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in available_entries:
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. "
|
||||
f"Available signals: '{available_entries}'"
|
||||
)
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
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:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._create_title_layout(title, icon)
|
||||
|
||||
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)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_expansion_icon()
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
return self._expanded
|
||||
|
||||
@expanded.setter
|
||||
def expanded(self, expanded: bool):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
self.adjustSize()
|
||||
|
||||
def _update_expansion_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
if self.expanded
|
||||
else material_icon(
|
||||
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()
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import NoneType
|
||||
from typing import NamedTuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
DynamicFormItemType,
|
||||
FormItemSpec,
|
||||
widget_from_type,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class GridRow(NamedTuple):
|
||||
i: int
|
||||
label: QLabel
|
||||
widget: DynamicFormItem
|
||||
|
||||
|
||||
class TypedForm(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "list_alt"
|
||||
|
||||
value_changed = Signal()
|
||||
|
||||
RPC = True
|
||||
USER_ACCESS = ["enabled", "enabled.setter"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
items: list[tuple[str, type]] | None = None,
|
||||
form_item_specs: list[FormItemSpec] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Widget with a list of form items based on a list of types.
|
||||
|
||||
Args:
|
||||
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
||||
Should be a type supported by the logic in items.py
|
||||
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
||||
only one of items or form_item_specs should be
|
||||
supplied.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
"""
|
||||
if items is not None and form_item_specs is not None:
|
||||
logger.error(
|
||||
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
|
||||
)
|
||||
items = None
|
||||
if items is None and form_item_specs is None:
|
||||
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||
items = []
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._items = form_item_specs or [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._enabled: bool = enabled
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
self._widget_from_type = widget_from_type
|
||||
self._post_init()
|
||||
|
||||
def _post_init(self):
|
||||
"""Override this if a subclass should do things after super().__init__ and before populate()"""
|
||||
self.populate()
|
||||
self.enabled = self._enabled # type: ignore # QProperty
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
for r, item in enumerate(self._items):
|
||||
self._add_griditem(item, r)
|
||||
gl: QGridLayout = self._form_grid.layout()
|
||||
gl.setRowStretch(gl.rowCount(), 1)
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def enumerate_form_widgets(self):
|
||||
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
||||
which the field name is attached as a property "_model_field_name"), and the entry widget"""
|
||||
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
||||
for i in range(grid.rowCount() - 1): # One extra row for stretch
|
||||
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
|
||||
return {
|
||||
row.label.property("_model_field_name"): row.widget.getValue()
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
self._form_grid.adjustSize()
|
||||
self._form_grid_container.adjustSize()
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
@property
|
||||
def widget_dict(self):
|
||||
return {
|
||||
row.label.property("_model_field_name"): row.widget
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enabled(self):
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
self.setEnabled(value)
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
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,
|
||||
):
|
||||
"""
|
||||
A form generated from a pydantic model.
|
||||
|
||||
Args:
|
||||
data_model (type[BaseModel]): the model class for which to generate a form.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
|
||||
"""
|
||||
self._pretty_display = pretty_display
|
||||
self._md_schema = data_model
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
form_item_specs=self._form_item_specs(),
|
||||
enabled=enabled,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Metadata validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
self._layout.addWidget(self._validity)
|
||||
self.value_changed.connect(self.validate_form)
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str = "dark"):
|
||||
if self._pretty_display:
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
def set_data(self, data: BaseModel):
|
||||
"""Fill the data for the form.
|
||||
|
||||
Args:
|
||||
data (BaseModel): the data to enter into the form. Must be the same type as the
|
||||
currently set schema, raises TypeError otherwise."""
|
||||
if not self._md_schema:
|
||||
raise ValueError("Schema not set - can't set data")
|
||||
if not isinstance(data, self._md_schema):
|
||||
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
|
||||
for form_item in self.enumerate_form_widgets():
|
||||
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
|
||||
|
||||
def _form_item_specs(self):
|
||||
return [
|
||||
FormItemSpec(
|
||||
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
|
||||
)
|
||||
for name, info in self._md_schema.model_fields.items()
|
||||
]
|
||||
|
||||
def update_items_from_schema(self):
|
||||
self._items = self._form_item_specs()
|
||||
|
||||
def populate(self):
|
||||
self.update_items_from_schema()
|
||||
super().populate()
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._dict_from_grid()
|
||||
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on metadata_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_form_data()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.metadata_updated.emit(metadata_dict)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
return False
|
||||
@@ -1,612 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Callable, Final, Iterable, Literal, NamedTuple, OrderedDict, get_args
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
field_default,
|
||||
field_limits,
|
||||
field_maxlen,
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FormItemSpec(BaseModel):
|
||||
"""
|
||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||
to store most annotation info, since one of the main purposes is to store data for
|
||||
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
item_type: type | UnionType | GenericAlias
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
pretty_display: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
|
||||
)
|
||||
|
||||
@field_validator("item_type", mode="before")
|
||||
@classmethod
|
||||
def _validate_type(cls, v):
|
||||
allowed_primitives = [str, int, float, bool]
|
||||
if isinstance(v, (type, UnionType)):
|
||||
return v
|
||||
if isinstance(v, GenericAlias):
|
||||
if v.__origin__ in [list, dict, set] and all(
|
||||
arg in allowed_primitives for arg in v.__args__
|
||||
):
|
||||
return v
|
||||
raise ValueError(
|
||||
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
|
||||
)
|
||||
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
|
||||
arg_types = set(type(arg) for arg in v.__args__)
|
||||
if len(arg_types) != 1:
|
||||
raise ValueError("Mixtures of literal types are not supported!")
|
||||
if (t := arg_types.pop()) in allowed_primitives:
|
||||
return t
|
||||
raise ValueError(f"Literals of type {t} are not supported")
|
||||
|
||||
|
||||
class ClearableBoolEntry(QWidget):
|
||||
stateChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._entry = QButtonGroup()
|
||||
self._true = QRadioButton("true", parent=self)
|
||||
self._false = QRadioButton("false", parent=self)
|
||||
for button in [self._true, self._false]:
|
||||
self._layout.addWidget(button)
|
||||
self._entry.addButton(button)
|
||||
button.toggled.connect(self.stateChanged)
|
||||
|
||||
def clear(self):
|
||||
self._entry.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._entry.setExclusive(True)
|
||||
|
||||
def isChecked(self) -> bool | None:
|
||||
if not self._true.isChecked() and not self._false.isChecked():
|
||||
return None
|
||||
return self._true.isChecked()
|
||||
|
||||
def setChecked(self, value: bool | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
elif value:
|
||||
self._true.setChecked(True)
|
||||
self._false.setChecked(False)
|
||||
else:
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(True)
|
||||
|
||||
def setToolTip(self, tooltip: str):
|
||||
self._true.setToolTip(tooltip)
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
"""
|
||||
Initializes the form item widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None, optional): The parent widget. Defaults to None.
|
||||
spec (FormItemSpec): The specification for the form item.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._spec = spec
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
|
||||
self._default = field_default(self._spec.info)
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._set_pretty_display()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self) -> DynamicFormItemType: ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
button.setVisible(False)
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
|
||||
if max_length:
|
||||
self._main_widget.setMaxLength(max_length)
|
||||
self._main_widget.setToolTip(
|
||||
f"(length min: {min_length} max: {max_length}){self._describe()}"
|
||||
)
|
||||
if self._default:
|
||||
self._main_widget.setText(self._default)
|
||||
self._add_clear_button()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.text()
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
return self._main_widget.setText("")
|
||||
self._main_widget.setText(str(value))
|
||||
|
||||
|
||||
class IntFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: int):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
precision = field_precision(self._spec.info)
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, float, precision)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
|
||||
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float | Decimal):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(float(value))
|
||||
|
||||
|
||||
class BoolFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
if clearable_required(self._spec.info):
|
||||
self._main_widget = ClearableBoolEntry()
|
||||
else:
|
||||
self._main_widget = QCheckBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.isChecked()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
class BoolToggleFormItem(BoolFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.default is PydanticUndefined:
|
||||
spec.info.default = False
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = ToggleSwitch()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
if self._default is not None:
|
||||
self._main_widget.setChecked(self._default)
|
||||
|
||||
|
||||
class DictFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.data_changed.connect(self._value_changed)
|
||||
if spec.info.default is not PydanticUndefined:
|
||||
self._main_widget.set_default(spec.info.default)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self._main_widget.set_button_visibility(False)
|
||||
super()._set_pretty_display()
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = DictBackedTable(self, [])
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.dump_dict()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple):
|
||||
# TODO: this should be generic but not supported in 3.10
|
||||
item: type[int | float | str]
|
||||
widget: type[QWidget]
|
||||
default: int | float | str
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.annotation is list:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
elif isinstance(spec.info.annotation, GenericAlias):
|
||||
args = set(typing.get_args(spec.info.annotation))
|
||||
if args == {str}:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
if args == {int}:
|
||||
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
|
||||
if args == {float} or args == {int, float}:
|
||||
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
|
||||
else:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._main_widget: QListWidget
|
||||
self._data = []
|
||||
self._min_lines = 2 if spec.pretty_display else 4
|
||||
self._repop(self._data)
|
||||
|
||||
def sizeHint(self):
|
||||
default = super().sizeHint()
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QListWidget()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self._add_buttons()
|
||||
|
||||
def _add_buttons(self):
|
||||
self._button_holder = QWidget()
|
||||
self._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
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value0: set = Field(set(["a", "b"]))
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
layout.addWidget(widg, i, 1)
|
||||
|
||||
items[6].setValue([1, 2, 3, 4])
|
||||
items[7].setValue(["1", "2", "asdfg", "qwerty"])
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
@@ -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())
|
||||
@@ -1,33 +1,17 @@
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "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):
|
||||
register: str
|
||||
plugin: str
|
||||
pyproj: str
|
||||
|
||||
|
||||
def plugin_filenames(name: str) -> PluginFilenames:
|
||||
return PluginFilenames(f"register_{name}.py", f"{name}_plugin.py", f"{name}.pyproject")
|
||||
|
||||
|
||||
class DesignerPluginInfo:
|
||||
def __init__(self, plugin_class):
|
||||
self.plugin_class = plugin_class
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
@@ -43,6 +27,21 @@ class DesignerPluginInfo:
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
@@ -54,15 +53,11 @@ class DesignerPluginGenerator:
|
||||
self._excluded = True
|
||||
return
|
||||
|
||||
self.templates: dict[str, str] = {}
|
||||
self.templates = {}
|
||||
self.template_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
|
||||
)
|
||||
|
||||
@property
|
||||
def filenames(self):
|
||||
return plugin_filenames(self.info.plugin_name_snake)
|
||||
|
||||
def run(self, validate=True):
|
||||
if self._excluded:
|
||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||
@@ -93,38 +88,50 @@ class DesignerPluginGenerator:
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
|
||||
cls_init_found = class_re.search(init_source) is not None
|
||||
super_self_re = re.compile(
|
||||
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
)
|
||||
super_init_found = super_self_re.search(init_source) is not None
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
|
||||
# for the new style classes, we only have one super call. We can therefore check if the
|
||||
# number of __init__ calls is 2 (the class itself and the super class)
|
||||
num_inits = re.findall(r"__init__", init_source)
|
||||
if len(num_inits) == 2 and not super_init_found:
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must call the super constructor with parent."
|
||||
)
|
||||
|
||||
def _write_file(self, name: str, contents: str):
|
||||
with open(os.path.join(self.info.base_path, name), "w", encoding="utf-8") as f:
|
||||
f.write(contents)
|
||||
|
||||
def _format(self, name: str):
|
||||
return self.templates[name].format(**self.info.__dict__)
|
||||
|
||||
def _write_templates(self):
|
||||
self._write_file(self.filenames.register, self._format("register"))
|
||||
self._write_file(self.filenames.plugin, self._format("plugin"))
|
||||
pyproj = str({"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]})
|
||||
self._write_file(self.filenames.pyproj, pyproj)
|
||||
self._write_register()
|
||||
self._write_plugin()
|
||||
self._write_pyproject()
|
||||
|
||||
def _write_register(self):
|
||||
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["register"].format(**self.info.__dict__))
|
||||
|
||||
def _write_plugin(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["plugin"].format(**self.info.__dict__))
|
||||
|
||||
def _write_pyproject(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
|
||||
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(str(out))
|
||||
|
||||
def _load_templates(self):
|
||||
for file in os.listdir(self.template_path):
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots."""
|
||||
|
||||
from __future__ import annotations
|
||||
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots. """
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import re
|
||||
|
||||
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
@@ -148,7 +148,10 @@ class BECTickItem(BECIndicatorItem):
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup the item"""
|
||||
self.remove_from_plot()
|
||||
self.tick_item = None
|
||||
if self.tick_item is not None:
|
||||
self.tick_item.close()
|
||||
self.tick_item.deleteLater()
|
||||
self.tick_item = None
|
||||
|
||||
|
||||
class BECArrowItem(BECIndicatorItem):
|
||||
@@ -171,7 +174,7 @@ class BECArrowItem(BECIndicatorItem):
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
|
||||
super().__init__(plot_item=plot_item, parent=parent)
|
||||
self.arrow_item = pg.ArrowItem()
|
||||
self.arrow_item = pg.ArrowItem(parent=parent)
|
||||
self.arrow_item.skip_auto_range = True
|
||||
self._pos = (0, 0)
|
||||
self.arrow_item.setVisible(False)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
@@ -12,9 +9,6 @@ from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
"""
|
||||
@@ -51,70 +45,20 @@ def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
|
||||
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
|
||||
specified in the respective pyproject.toml file using the following key:
|
||||
[project.entry-points."bec.widgets.auto_updates"]
|
||||
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
|
||||
|
||||
e.g.
|
||||
[project.entry-points."bec.widgets.auto_updates"]
|
||||
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
|
||||
|
||||
Returns:
|
||||
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.auto_updates")
|
||||
loaded_plugins = {}
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
|
||||
for name, mod_cls in mods:
|
||||
if name in loaded_plugins:
|
||||
print(f"Duplicated auto update {name}.")
|
||||
loaded_plugins[name] = mod_cls
|
||||
return loaded_plugins
|
||||
|
||||
|
||||
def _filter_auto_updates(obj):
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
|
||||
return (
|
||||
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type[BECWidget]
|
||||
obj: type
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_plugin: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self, initial: Iterable[BECClassInfo] = []):
|
||||
self._collection: list[BECClassInfo] = list(initial)
|
||||
|
||||
def __repr__(self):
|
||||
return str(list(cl.name for cl in self.collection))
|
||||
|
||||
def __iter__(self):
|
||||
return self._collection.__iter__()
|
||||
|
||||
def __add__(self, other: BECClassContainer):
|
||||
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
|
||||
|
||||
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
|
||||
"""get a dict of {name: Type} for all the entries in the collection.
|
||||
|
||||
Args:
|
||||
ignores(list[str]): a list of class names to exclude from the dictionary."""
|
||||
return {c.name: c.obj for c in self if c.name not in ignores}
|
||||
def __init__(self):
|
||||
self._collection = []
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
@@ -125,44 +69,53 @@ class BECClassContainer:
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
"""Return a list of class names"""
|
||||
return [c.name for c in self]
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""Get the collection of classes."""
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""Get all connector classes."""
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""Get all top-level classes."""
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_plugin]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""Get all plugins. These are all classes that are on the top level and are widgets."""
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""Get all widgets. These are all classes inheriting from BECWidget."""
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
"""Get all classes."""
|
||||
"""
|
||||
Get all classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
@@ -198,7 +151,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):
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Callable, TypeVar
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
from qtpy.QtCore import QObject
|
||||
else:
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rpc_exception_hook(err_func):
|
||||
"""This context replaces the popup message box for error display with a specific hook"""
|
||||
# get error popup utility singleton
|
||||
popup = ErrorPopupUtility()
|
||||
# save current setting
|
||||
old_exception_hook = popup.custom_exception_hook
|
||||
|
||||
# install err_func, if it is a callable
|
||||
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
|
||||
# of the ErrorPopupUtility (popup instance) class.
|
||||
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
|
||||
err_func({"error": popup.get_error_message(exc_type, value, tb)})
|
||||
|
||||
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
|
||||
|
||||
try:
|
||||
yield popup
|
||||
finally:
|
||||
# restore state of error popup utility singleton
|
||||
popup.custom_exception_hook = old_exception_hook
|
||||
|
||||
|
||||
class RPCServer:
|
||||
|
||||
client: BECClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str,
|
||||
dispatcher: BECDispatcher | None = None,
|
||||
client: BECClient | None = None,
|
||||
config=None,
|
||||
gui_class_id: str = "bec",
|
||||
) -> None:
|
||||
self.status = messages.BECStatus.BUSY
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
# register broadcast callback
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_callback(self.broadcast_registry_update)
|
||||
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
)
|
||||
|
||||
# Setup QTimer for heartbeat
|
||||
self._heartbeat_timer = QTimer()
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200)
|
||||
self._registry_update_callbacks = []
|
||||
self._broadcasted_data = {}
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
if request_id is None:
|
||||
logger.error("Received RPC instruction without request_id")
|
||||
return
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.error(f"Error while executing RPC instruction: {content}")
|
||||
self.send_response(request_id, False, {"error": content})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def get_object_from_config(self, config: dict):
|
||||
gui_id = config.get("gui_id")
|
||||
obj = self.rpc_register.get_rpc_by_id(gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Object with gui_id {gui_id} not found")
|
||||
return obj
|
||||
|
||||
def run_rpc(self, obj, method, args, kwargs):
|
||||
# Run with rpc registry broadcast, but only once
|
||||
with RPCRegister.delayed_broadcast():
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
elif isinstance(res, dict):
|
||||
res = {key: self.serialize_object(val) for key, val in res.items()}
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
return res
|
||||
|
||||
def serialize_object(self, obj: T) -> None | dict | T:
|
||||
"""
|
||||
Serialize all BECConnector objects.
|
||||
|
||||
Args:
|
||||
obj: The object to be serialized.
|
||||
|
||||
Returns:
|
||||
None | dict | T: The serialized object or None if the object is not a BECConnector.
|
||||
"""
|
||||
if not isinstance(obj, BECConnector):
|
||||
return obj
|
||||
# Respect RPC = False
|
||||
if getattr(obj, "RPC", True) is False:
|
||||
return None
|
||||
return self._serialize_bec_connector(obj, wait=True)
|
||||
|
||||
def emit_heartbeat(self) -> None:
|
||||
"""
|
||||
Emit a heartbeat message to the GUI server.
|
||||
This method is called periodically to indicate that the server is still running.
|
||||
"""
|
||||
logger.trace(f"Emitting heartbeat for {self.gui_id}")
|
||||
try:
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
|
||||
expire=10,
|
||||
)
|
||||
except RedisError as exc:
|
||||
logger.error(f"Error while emitting heartbeat: {exc}")
|
||||
|
||||
def broadcast_registry_update(self, connections: dict) -> None:
|
||||
"""
|
||||
Broadcast the registry update to all the callbacks.
|
||||
This method is called whenever the registry is updated.
|
||||
"""
|
||||
data = {}
|
||||
for key, val in connections.items():
|
||||
if not isinstance(val, BECConnector):
|
||||
continue
|
||||
if not getattr(val, "RPC", True):
|
||||
continue
|
||||
data[key] = self._serialize_bec_connector(val)
|
||||
if self._broadcasted_data == data:
|
||||
return
|
||||
self._broadcasted_data = data
|
||||
|
||||
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1,
|
||||
)
|
||||
|
||||
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
|
||||
"""
|
||||
Create the serialization dict for a single BECConnector.
|
||||
|
||||
Args:
|
||||
connector (BECConnector): The BECConnector to serialize.
|
||||
wait (bool): If True, wait until the object is registered in the RPC register.
|
||||
|
||||
Returns:
|
||||
dict: The serialized BECConnector object.
|
||||
"""
|
||||
|
||||
config_dict = connector.config.model_dump()
|
||||
config_dict["parent_id"] = getattr(connector, "parent_id", None)
|
||||
|
||||
try:
|
||||
parent = connector.parent()
|
||||
if isinstance(parent, BECMainWindow):
|
||||
container_proxy = parent.gui_id
|
||||
else:
|
||||
container_proxy = None
|
||||
except Exception:
|
||||
container_proxy = None
|
||||
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
widget_class = connector.__class__.__name__
|
||||
|
||||
return {
|
||||
"gui_id": connector.gui_id,
|
||||
"object_name": connector.object_name or connector.__class__.__name__,
|
||||
"widget_class": widget_class,
|
||||
"config": config_dict,
|
||||
"container_proxy": container_proxy,
|
||||
"__rpc__": True,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest BECConnector.
|
||||
Returns None if none is found.
|
||||
"""
|
||||
|
||||
parent = widget.parent()
|
||||
while parent is not None:
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
return None
|
||||
|
||||
# Suppose clients register callbacks to receive updates
|
||||
def add_registry_update_callback(self, cb: Callable) -> None:
|
||||
"""
|
||||
Add a callback to be called whenever the registry is updated.
|
||||
The specified callback is called whenever the registry is updated.
|
||||
|
||||
Args:
|
||||
cb (Callable): The callback to be added. It should accept a dictionary of all the
|
||||
registered RPC objects as an argument.
|
||||
"""
|
||||
self._registry_update_callbacks.append(cb)
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
self.status = messages.BECStatus.IDLE
|
||||
self._heartbeat_timer.stop()
|
||||
self.emit_heartbeat()
|
||||
logger.info("Succeded in shutting down CLI server")
|
||||
self.client.shutdown()
|
||||
@@ -1,44 +0,0 @@
|
||||
from bec_lib.serialization import msgpack
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
|
||||
def register_serializer_extension():
|
||||
"""
|
||||
Register the serializer extension for the BECConnector.
|
||||
"""
|
||||
if not module_is_registered("bec_widgets.utils.serialization"):
|
||||
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
|
||||
|
||||
|
||||
def module_is_registered(module_name: str) -> bool:
|
||||
"""
|
||||
Check if the module is registered in the encoder.
|
||||
|
||||
Args:
|
||||
module_name (str): The name of the module to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the module is registered, False otherwise.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
for enc in msgpack._encoder:
|
||||
if enc[0].__module__ == module_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def encode_qpointf(obj):
|
||||
"""
|
||||
Encode a QPointF object to a list of floats. As this is mostly used for sending
|
||||
data to the client, it is not necessary to convert it back to a QPointF object.
|
||||
"""
|
||||
if isinstance(obj, QPointF):
|
||||
return [obj.x(), obj.y()]
|
||||
return obj
|
||||
|
||||
|
||||
def decode_qpointf(obj):
|
||||
"""
|
||||
no-op function since QPointF is encoded as a list of floats.
|
||||
"""
|
||||
return obj
|
||||
@@ -1,18 +1,16 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import PYQT6, PYSIDE6
|
||||
import os
|
||||
|
||||
from qtpy import PYQT6, PYSIDE6, QT_VERSION
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if PYSIDE6:
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
||||
def __init__(self, baseinstance, custom_widgets: dict = None):
|
||||
super().__init__(baseinstance)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
@@ -20,9 +18,10 @@ if PYSIDE6:
|
||||
|
||||
def createWidget(self, class_name, parent=None, name=""):
|
||||
if class_name in self.custom_widgets:
|
||||
widget = self.custom_widgets[class_name](self.baseinstance)
|
||||
widget = self.custom_widgets[class_name](parent)
|
||||
widget.setObjectName(name)
|
||||
return widget
|
||||
return super().createWidget(class_name, self.baseinstance, name)
|
||||
return super().createWidget(class_name, parent, name)
|
||||
|
||||
|
||||
class UILoader:
|
||||
@@ -31,9 +30,9 @@ class UILoader:
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
self.custom_widgets = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict()
|
||||
widgets = get_custom_classes("bec_widgets").classes
|
||||
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
@@ -52,7 +51,7 @@ class UILoader:
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
parent = parent or self.parent
|
||||
|
||||
loader = CustomUiLoader(parent, self.custom_widgets)
|
||||
file = QFile(ui_file)
|
||||
if not file.open(QIODevice.ReadOnly):
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import shiboken6 as shb
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -11,7 +8,6 @@ from qtpy.QtWidgets import (
|
||||
QDoubleSpinBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -108,10 +104,10 @@ class TableWidgetHandler(WidgetHandler):
|
||||
class SpinBoxHandler(WidgetHandler):
|
||||
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
||||
|
||||
def get_value(self, widget: QSpinBox | QDoubleSpinBox, **kwargs):
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.value()
|
||||
|
||||
def set_value(self, widget: QSpinBox | QDoubleSpinBox, value):
|
||||
def set_value(self, widget, value):
|
||||
widget.setValue(value)
|
||||
|
||||
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
|
||||
@@ -121,36 +117,23 @@ class SpinBoxHandler(WidgetHandler):
|
||||
class CheckBoxHandler(WidgetHandler):
|
||||
"""Handler for QCheckBox widgets."""
|
||||
|
||||
def get_value(self, widget: QCheckBox, **kwargs):
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.isChecked()
|
||||
|
||||
def set_value(self, widget: QCheckBox, value):
|
||||
def set_value(self, widget, value):
|
||||
widget.setChecked(value)
|
||||
|
||||
def connect_change_signal(self, widget: QCheckBox, slot):
|
||||
widget.toggled.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class SlideHandler(WidgetHandler):
|
||||
"""Handler for QCheckBox widgets."""
|
||||
|
||||
def get_value(self, widget: QSlider, **kwargs):
|
||||
return widget.value()
|
||||
|
||||
def set_value(self, widget: QSlider, value):
|
||||
widget.setValue(value)
|
||||
|
||||
def connect_change_signal(self, widget: QSlider, slot):
|
||||
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
|
||||
|
||||
|
||||
class ToggleSwitchHandler(WidgetHandler):
|
||||
"""Handler for ToggleSwitch widgets."""
|
||||
|
||||
def get_value(self, widget: ToggleSwitch, **kwargs):
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.checked
|
||||
|
||||
def set_value(self, widget: ToggleSwitch, value):
|
||||
def set_value(self, widget, value):
|
||||
widget.checked = value
|
||||
|
||||
def connect_change_signal(self, widget: ToggleSwitch, slot):
|
||||
@@ -160,7 +143,7 @@ class ToggleSwitchHandler(WidgetHandler):
|
||||
class LabelHandler(WidgetHandler):
|
||||
"""Handler for QLabel widgets."""
|
||||
|
||||
def get_value(self, widget: QLabel, **kwargs):
|
||||
def get_value(self, widget, **kwargs):
|
||||
return widget.text()
|
||||
|
||||
def set_value(self, widget: QLabel, value):
|
||||
@@ -182,7 +165,6 @@ class WidgetIO:
|
||||
QCheckBox: CheckBoxHandler,
|
||||
QLabel: LabelHandler,
|
||||
ToggleSwitch: ToggleSwitchHandler,
|
||||
QSlider: SlideHandler,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -276,162 +258,39 @@ class WidgetHierarchy:
|
||||
grab_values: bool = False,
|
||||
prefix: str = "",
|
||||
exclude_internal_widgets: bool = True,
|
||||
only_bec_widgets: bool = False,
|
||||
show_parent: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Print the widget hierarchy to the console.
|
||||
|
||||
Args:
|
||||
widget: Widget to print the hierarchy of.
|
||||
widget: Widget to print the hierarchy of
|
||||
indent(int, optional): Level of indentation.
|
||||
grab_values(bool,optional): Whether to grab the values of the widgets.
|
||||
prefix(str,optional): Custom string prefix for indentation.
|
||||
prefix(stc,optional): Custom string prefix for indentation.
|
||||
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
|
||||
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
|
||||
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
|
||||
if grab_values:
|
||||
value = WidgetIO.get_value(widget, ignore_errors=True)
|
||||
value_str = f" [value: {value}]" if value is not None else ""
|
||||
widget_info += value_str
|
||||
|
||||
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
|
||||
is_bec = isinstance(widget, BECConnector)
|
||||
if only_bec_widgets and not is_bec:
|
||||
return
|
||||
|
||||
# 2) Determine and print the parent's info (closest BECConnector)
|
||||
parent_info = ""
|
||||
if show_parent and is_bec:
|
||||
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if ancestor:
|
||||
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
||||
parent_info = f" parent={parent_label}"
|
||||
else:
|
||||
parent_info = " parent=None"
|
||||
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
|
||||
print(prefix + widget_info)
|
||||
|
||||
# 3) If it's a Waveform, explicitly print the curves
|
||||
if isinstance(widget, Waveform):
|
||||
for curve in widget.curves:
|
||||
curve_prefix = prefix + " └─ "
|
||||
print(
|
||||
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
|
||||
f"parent={widget.objectName()}"
|
||||
)
|
||||
|
||||
# 4) Recursively handle each child if:
|
||||
# - It's a QWidget
|
||||
# - It is a BECConnector (or we don't care about filtering)
|
||||
# - Its closest BECConnector parent is the current widget
|
||||
for child in widget.findChildren(QWidget):
|
||||
if only_bec_widgets and not isinstance(child, BECConnector):
|
||||
children = widget.children()
|
||||
for child in children:
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
|
||||
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
|
||||
child_prefix = prefix + " └─ "
|
||||
child_prefix = prefix + " "
|
||||
arrow = "├─ " if child != children[-1] else "└─ "
|
||||
WidgetHierarchy.print_widget_hierarchy(
|
||||
child,
|
||||
indent=indent + 1,
|
||||
grab_values=grab_values,
|
||||
prefix=child_prefix,
|
||||
exclude_internal_widgets=exclude_internal_widgets,
|
||||
only_bec_widgets=only_bec_widgets,
|
||||
show_parent=show_parent,
|
||||
child, indent + 1, grab_values, prefix=child_prefix + arrow
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def print_becconnector_hierarchy_from_app():
|
||||
"""
|
||||
Enumerate ALL BECConnector objects in the QApplication.
|
||||
Also detect if a widget is a PlotBase, and add any data items
|
||||
(PlotDataItem-like) that are also BECConnector objects.
|
||||
|
||||
Build a parent->children graph where each child's 'parent'
|
||||
is its closest BECConnector ancestor. Print the entire hierarchy
|
||||
from the root(s).
|
||||
|
||||
The result is a single, consolidated tree for your entire
|
||||
running GUI, including PlotBase data items that are BECConnector.
|
||||
"""
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
|
||||
# 1) Gather ALL QWidget-based BECConnector objects
|
||||
all_qwidgets = QApplication.allWidgets()
|
||||
bec_widgets = set(w for w in all_qwidgets if isinstance(w, BECConnector))
|
||||
|
||||
# 2) Also gather any BECConnector-based data items from PlotBase widgets
|
||||
for w in all_qwidgets:
|
||||
if isinstance(w, PlotBase) and hasattr(w, "plot_item"):
|
||||
plot_item = w.plot_item
|
||||
if hasattr(plot_item, "listDataItems"):
|
||||
for data_item in plot_item.listDataItems():
|
||||
if isinstance(data_item, BECConnector):
|
||||
bec_widgets.add(data_item)
|
||||
|
||||
# 3) Build a map of (closest BECConnector parent) -> list of children
|
||||
parent_map = defaultdict(list)
|
||||
for w in bec_widgets:
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
|
||||
parent_map[parent_bec].append(w)
|
||||
|
||||
# 4) Define a recursive printer to show each object's children
|
||||
def print_tree(parent, prefix=""):
|
||||
children = parent_map[parent]
|
||||
for i, child in enumerate(children):
|
||||
connector_class = child.__class__.__name__
|
||||
connector_name = child.objectName() or connector_class
|
||||
|
||||
if parent is None:
|
||||
parent_label = "None"
|
||||
else:
|
||||
parent_label = parent.objectName() or parent.__class__.__name__
|
||||
|
||||
line = f"{connector_class} ({connector_name}) parent={parent_label}"
|
||||
# Determine tree-branch symbols
|
||||
is_last = i == len(children) - 1
|
||||
branch_str = "└─ " if is_last else "├─ "
|
||||
print(prefix + branch_str + line)
|
||||
|
||||
# Recurse deeper
|
||||
next_prefix = prefix + (" " if is_last else "│ ")
|
||||
print_tree(child, prefix=next_prefix)
|
||||
|
||||
# 5) Print top-level items (roots) whose BECConnector parent is None
|
||||
roots = parent_map[None]
|
||||
for r_i, root in enumerate(roots):
|
||||
root_class = root.__class__.__name__
|
||||
root_name = root.objectName() or root_class
|
||||
line = f"{root_class} ({root_name}) parent=None"
|
||||
is_last_root = r_i == len(roots) - 1
|
||||
print(line)
|
||||
# Recurse into its children
|
||||
print_tree(root, prefix=" ")
|
||||
|
||||
@staticmethod
|
||||
def _get_becwidget_ancestor(widget):
|
||||
"""
|
||||
Traverse up the parent chain to find the nearest BECConnector.
|
||||
Returns None if none is found.
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
if not shb.isValid(widget):
|
||||
return None
|
||||
parent = widget.parent()
|
||||
while parent is not None:
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def export_config_to_dict(
|
||||
widget: QWidget,
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanStatusMessage
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AutoUpdates(BECMainWindow):
|
||||
_default_dock: BECDock
|
||||
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
|
||||
RPC = True
|
||||
|
||||
# enforce that subclasses have the same rpc widget class
|
||||
rpc_widget_class = "AutoUpdates"
|
||||
|
||||
def __init__(
|
||||
self, parent=None, gui_id: str = None, window_title="Auto Update", *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
|
||||
self.setCentralWidget(self.dock_area)
|
||||
self._auto_update_selected_device: str | None = None
|
||||
|
||||
self._default_dock = None # type:ignore
|
||||
self.current_widget: BECWidget | None = None
|
||||
self.dock_name = None
|
||||
self._enabled = True
|
||||
self.start_auto_update()
|
||||
|
||||
def start_auto_update(self):
|
||||
"""
|
||||
Establish all connections for the auto updates.
|
||||
"""
|
||||
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
|
||||
|
||||
def stop_auto_update(self):
|
||||
"""
|
||||
Disconnect all connections for the auto updates.
|
||||
"""
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
|
||||
)
|
||||
|
||||
@property
|
||||
def selected_device(self) -> str | None:
|
||||
"""
|
||||
Get the selected device from the auto update config.
|
||||
|
||||
Returns:
|
||||
str: The selected device. If no device is selected, None is returned.
|
||||
"""
|
||||
return self._auto_update_selected_device
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, value: str | None) -> None:
|
||||
"""
|
||||
Set the selected device in the auto update config.
|
||||
|
||||
Args:
|
||||
value(str): The selected device.
|
||||
"""
|
||||
self._auto_update_selected_device = value
|
||||
|
||||
@SafeSlot()
|
||||
def _on_scan_status(self, content: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Callback for scan status messages.
|
||||
"""
|
||||
msg = ScanStatusMessage(**content, metadata=metadata)
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
self.enable_gui_highlights(True)
|
||||
|
||||
match msg.status:
|
||||
case "open":
|
||||
self.on_scan_open(msg)
|
||||
case "closed":
|
||||
self.on_scan_closed(msg)
|
||||
case ["aborted", "halted"]:
|
||||
self.on_scan_abort(msg)
|
||||
case _:
|
||||
pass
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
self.dock_name = "update_dock"
|
||||
self._default_dock = self.dock_area.new(self.dock_name)
|
||||
self.current_widget = self._default_dock.new("Waveform")
|
||||
|
||||
@overload
|
||||
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
|
||||
|
||||
@overload
|
||||
def set_dock_to_widget(self, widget: Literal["Image"]) -> Image: ...
|
||||
|
||||
@overload
|
||||
def set_dock_to_widget(self, widget: Literal["ScatterWaveform"]) -> ScatterWaveform: ...
|
||||
|
||||
@overload
|
||||
def set_dock_to_widget(self, widget: Literal["MotorMap"]) -> MotorMap: ...
|
||||
|
||||
@overload
|
||||
def set_dock_to_widget(self, widget: Literal["MultiWaveform"]) -> MultiWaveform: ...
|
||||
|
||||
def set_dock_to_widget(
|
||||
self,
|
||||
widget: Literal["Waveform", "Image", "ScatterWaveform", "MotorMap", "MultiWaveForm"] | str,
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Set the dock to the widget.
|
||||
|
||||
Args:
|
||||
widget (str): The widget to set the dock to. Must be the name of a valid widget class.
|
||||
|
||||
Returns:
|
||||
BECWidget: The widget that was set.
|
||||
"""
|
||||
if self._default_dock is None or self.current_widget is None:
|
||||
logger.warning(
|
||||
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
|
||||
)
|
||||
self.start_default_dock()
|
||||
assert self.current_widget is not None
|
||||
|
||||
if not self.current_widget.__class__.__name__ == widget:
|
||||
self._default_dock.delete(self.current_widget.object_name)
|
||||
self.current_widget = self._default_dock.new(widget)
|
||||
return self.current_widget
|
||||
|
||||
def get_selected_device(
|
||||
self, monitored_devices, selected_device: str | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Get the selected device for the plot. If no device is selected, the first
|
||||
device in the monitored devices list is selected.
|
||||
"""
|
||||
|
||||
if selected_device is None:
|
||||
selected_device = self.selected_device
|
||||
if selected_device:
|
||||
return selected_device
|
||||
if len(monitored_devices) > 0:
|
||||
sel_device = monitored_devices[0]
|
||||
return sel_device
|
||||
return None
|
||||
|
||||
def enable_gui_highlights(self, enable: bool) -> None:
|
||||
"""
|
||||
Enable or disable GUI highlights.
|
||||
|
||||
Args:
|
||||
enable (bool): Whether to enable or disable the highlights.
|
||||
"""
|
||||
if enable:
|
||||
title = self.dock_area.window().windowTitle()
|
||||
if " [Auto Updates]" in title:
|
||||
return
|
||||
self.dock_area.window().setWindowTitle(f"{title} [Auto Updates]")
|
||||
else:
|
||||
title = self.dock_area.window().windowTitle()
|
||||
self.dock_area.window().setWindowTitle(title.replace(" [Auto Updates]", ""))
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""
|
||||
Get the enabled status of the auto updates.
|
||||
"""
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool) -> None:
|
||||
"""
|
||||
Set the enabled status of the auto updates.
|
||||
"""
|
||||
if self._enabled == value:
|
||||
return
|
||||
self._enabled = value
|
||||
|
||||
if value:
|
||||
self.start_auto_update()
|
||||
self.enable_gui_highlights(True)
|
||||
self.on_start()
|
||||
else:
|
||||
self.stop_auto_update()
|
||||
self.enable_gui_highlights(False)
|
||||
self.on_stop()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Cleanup procedure to run when the auto updates are disabled.
|
||||
"""
|
||||
self.enabled = False
|
||||
self.stop_auto_update()
|
||||
self.dock_area.close()
|
||||
self.dock_area.deleteLater()
|
||||
self.dock_area = None
|
||||
super().cleanup()
|
||||
|
||||
########################################################################
|
||||
################# Update Functions #####################################
|
||||
########################################################################
|
||||
|
||||
def simple_line_scan(self, info: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Simple line scan.
|
||||
|
||||
Args:
|
||||
info (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
|
||||
# Set the dock to the waveform widget
|
||||
wf = self.set_dock_to_widget("Waveform")
|
||||
|
||||
# Get the scan report devices reported by the scan
|
||||
dev_x = info.scan_report_devices[0] # type:ignore
|
||||
|
||||
# For the y axis, get the selected device
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
if not dev_y:
|
||||
return
|
||||
|
||||
# Clear the waveform widget and plot the data
|
||||
# with the scan number and device names
|
||||
# as the label and title
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
|
||||
)
|
||||
|
||||
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Simple grid scan.
|
||||
|
||||
Args:
|
||||
info (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
# Set the dock to the scatter waveform widget
|
||||
scatter = self.set_dock_to_widget("ScatterWaveform")
|
||||
|
||||
# Get the scan report devices reported by the scan
|
||||
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
|
||||
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
|
||||
if None in (dev_x, dev_y, dev_z):
|
||||
return
|
||||
|
||||
# Clear the scatter waveform widget and plot the data
|
||||
scatter.clear_all()
|
||||
scatter.plot(
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
|
||||
)
|
||||
|
||||
def best_effort(self, info: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Best effort scan.
|
||||
|
||||
Args:
|
||||
info (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
|
||||
# If the scan report devices are empty, there is nothing we can do
|
||||
if not info.scan_report_devices:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0] # type:ignore
|
||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||
if not dev_y:
|
||||
return
|
||||
|
||||
# Set the dock to the waveform widget
|
||||
wf = self.set_dock_to_widget("Waveform")
|
||||
|
||||
# Clear the waveform widget and plot the data
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {info.scan_number} - {dev_y}",
|
||||
title=f"Scan {info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
|
||||
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
|
||||
|
||||
#######################################################################
|
||||
################# GUI Callbacks #######################################
|
||||
#######################################################################
|
||||
|
||||
def on_start(self) -> None:
|
||||
"""
|
||||
Procedure to run when the auto updates are enabled.
|
||||
"""
|
||||
self.start_default_dock()
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""
|
||||
Procedure to run when the auto updates are disabled.
|
||||
"""
|
||||
|
||||
def on_scan_open(self, msg: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Procedure to run when a scan starts.
|
||||
|
||||
Args:
|
||||
msg (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
if msg.scan_name == "line_scan" and msg.scan_report_devices:
|
||||
return self.simple_line_scan(msg)
|
||||
if msg.scan_name == "grid_scan" and msg.scan_report_devices:
|
||||
return self.simple_grid_scan(msg)
|
||||
if msg.scan_report_devices:
|
||||
return self.best_effort(msg)
|
||||
return None
|
||||
|
||||
def on_scan_closed(self, msg: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Procedure to run when a scan ends.
|
||||
|
||||
Args:
|
||||
msg (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
|
||||
def on_scan_abort(self, msg: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Procedure to run when a scan is aborted.
|
||||
|
||||
Args:
|
||||
msg (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
@@ -1,33 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea import Dock, DockLabel
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockConfig(ConnectionConfig):
|
||||
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
|
||||
"bottom", description="The position of the dock."
|
||||
)
|
||||
parent_dock_area: Optional[str] | None = Field(
|
||||
parent_dock_area: Optional[str] = Field(
|
||||
None, description="The GUI ID of parent dock area of the dock."
|
||||
)
|
||||
|
||||
@@ -111,17 +103,16 @@ class BECDock(BECWidget, Dock):
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"element_list",
|
||||
"elements",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"_rpc_id",
|
||||
"widget_list",
|
||||
"show_title_bar",
|
||||
"set_title",
|
||||
"hide_title_bar",
|
||||
"available_widgets",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"get_widgets_positions",
|
||||
"set_title",
|
||||
"add_widget",
|
||||
"list_eligible_widgets",
|
||||
"move_widget",
|
||||
"remove_widget",
|
||||
"remove",
|
||||
"attach",
|
||||
"detach",
|
||||
@@ -130,38 +121,29 @@ class BECDock(BECWidget, Dock):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
parent_dock_area: QWidget | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
object_name: str | None = None,
|
||||
client=None,
|
||||
gui_id: str | None = None,
|
||||
closable: bool = True,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
if config is None:
|
||||
config = DockConfig(
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
|
||||
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
|
||||
)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DockConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
label = CustomDockLabel(text=name, closable=closable)
|
||||
super().__init__(
|
||||
parent=parent_dock_area,
|
||||
name=name,
|
||||
object_name=object_name,
|
||||
client=client,
|
||||
gui_id=gui_id,
|
||||
config=config,
|
||||
label=label,
|
||||
**kwargs,
|
||||
)
|
||||
Dock.__init__(self, name=name, label=label, **kwargs)
|
||||
# Dock.__init__(self, name=name, **kwargs)
|
||||
|
||||
self.parent_dock_area = parent_dock_area
|
||||
|
||||
# Layout Manager
|
||||
self.layout_manager = GridLayoutManager(self.layout)
|
||||
|
||||
@@ -191,18 +173,7 @@ class BECDock(BECWidget, Dock):
|
||||
super().float()
|
||||
|
||||
@property
|
||||
def elements(self) -> dict[str, BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
Returns:
|
||||
widgets(dict): The widgets in the dock.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
return dict((widget.object_name, widget) for widget in self.element_list)
|
||||
|
||||
@property
|
||||
def element_list(self) -> list[BECWidget]:
|
||||
def widget_list(self) -> list[BECWidget]:
|
||||
"""
|
||||
Get the widgets in the dock.
|
||||
|
||||
@@ -211,6 +182,10 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.widgets
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECWidget]):
|
||||
self.widgets = value
|
||||
|
||||
def hide_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -219,20 +194,6 @@ class BECDock(BECWidget, Dock):
|
||||
self.label.hide()
|
||||
self.labelHidden = True
|
||||
|
||||
def show(self):
|
||||
"""
|
||||
Show the dock.
|
||||
"""
|
||||
super().show()
|
||||
self.show_title_bar()
|
||||
|
||||
def hide(self):
|
||||
"""
|
||||
Hide the dock.
|
||||
"""
|
||||
self.hide_title_bar()
|
||||
super().hide()
|
||||
|
||||
def show_title_bar(self):
|
||||
"""
|
||||
Hide the title bar of the dock.
|
||||
@@ -250,6 +211,7 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
|
||||
self.setTitle(title)
|
||||
self._name = title
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
@@ -260,7 +222,7 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return self.layout_manager.get_widgets_positions()
|
||||
|
||||
def available_widgets(
|
||||
def list_eligible_widgets(
|
||||
self,
|
||||
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
|
||||
"""
|
||||
@@ -271,65 +233,42 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
|
||||
if (docks := self.parent_dock_area.panel_list) is None:
|
||||
return []
|
||||
widgets = []
|
||||
for dock in docks:
|
||||
widgets.extend(dock.elements.keys())
|
||||
return widgets
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
def add_widget(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
name: str | None = None,
|
||||
row: int | None = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
) -> BECWidget:
|
||||
"""
|
||||
Add a widget to the dock.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
|
||||
name(str): The name of the widget.
|
||||
widget(QWidget): The widget to add.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to.
|
||||
rowspan(int): The number of rows the widget should span.
|
||||
colspan(int): The number of columns the widget should span.
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
|
||||
if self.layout_manager.is_position_occupied(row, col):
|
||||
self.layout_manager.shift_widgets(shift, start_row=row)
|
||||
|
||||
# Check that Widget is not BECDock or BECDockArea
|
||||
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
|
||||
if widget_class_name in IGNORE_WIDGETS:
|
||||
raise ValueError(f"Widget {widget} can not be added to dock.")
|
||||
|
||||
if isinstance(widget, str):
|
||||
widget = cast(
|
||||
BECWidget,
|
||||
widget_handler.create_widget(
|
||||
widget_type=widget, object_name=name, parent_dock=self, parent=self
|
||||
),
|
||||
)
|
||||
widget = widget_handler.create_widget(widget)
|
||||
else:
|
||||
widget.object_name = name
|
||||
widget = widget
|
||||
|
||||
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
|
||||
if hasattr(widget, "config"):
|
||||
widget.config.gui_id = widget.gui_id
|
||||
self.config.widgets[widget.object_name] = widget.config
|
||||
self.config.widgets[widget.gui_id] = widget.config
|
||||
|
||||
return widget
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
@@ -355,68 +294,36 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.float()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
self.parent_dock_area.delete(self.object_name)
|
||||
|
||||
def delete(self, widget_name: str) -> None:
|
||||
def remove_widget(self, widget_rpc_id: str):
|
||||
"""
|
||||
Remove a widget from the dock.
|
||||
|
||||
Args:
|
||||
widget_name(str): Delete the widget with the given name.
|
||||
widget_rpc_id(str): The ID of the widget to remove.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
|
||||
if len(widgets) == 0:
|
||||
logger.warning(
|
||||
f"Widget with name {widget_name} not found in dock {self.name()}. "
|
||||
f"Checking if gui_id was passed as widget_name."
|
||||
)
|
||||
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_name)
|
||||
if widget is None:
|
||||
logger.warning(
|
||||
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
widget = widgets[0]
|
||||
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
|
||||
self.layout.removeWidget(widget)
|
||||
self.config.widgets.pop(widget.object_name, None)
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
self.config.widgets.pop(widget_rpc_id, None)
|
||||
widget.close()
|
||||
|
||||
def delete_all(self):
|
||||
def remove(self):
|
||||
"""
|
||||
Remove all widgets from the dock.
|
||||
Remove the dock from the parent dock area.
|
||||
"""
|
||||
for widget in self.widgets:
|
||||
self.delete(widget.object_name)
|
||||
# self.cleanup()
|
||||
self.parent_dock_area.remove_dock(self.name())
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the dock, including all its widgets.
|
||||
"""
|
||||
# # FIXME Cleanup might be called twice
|
||||
try:
|
||||
logger.info(f"Cleaning up dock {self.name()}")
|
||||
self.label.close()
|
||||
self.label.deleteLater()
|
||||
except Exception as e:
|
||||
logger.error(f"Error while closing dock label: {e}")
|
||||
|
||||
# Remove the dock from the parent dock area
|
||||
if self.parent_dock_area:
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
self.parent_dock_area.config.docks.pop(self.name(), None)
|
||||
self.delete_all()
|
||||
for widget in self.widgets:
|
||||
if hasattr(widget, "cleanup"):
|
||||
widget.cleanup()
|
||||
self.widgets.clear()
|
||||
self.label.close()
|
||||
self.label.deleteLater()
|
||||
super().cleanup()
|
||||
self.deleteLater()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
@@ -425,15 +332,4 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
dock = BECDock(name="dock")
|
||||
dock.show()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
|
||||
|
||||
@@ -3,43 +3,36 @@ from __future__ import annotations
|
||||
from typing import Literal, Optional
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field
|
||||
from pyqtgraph.dockarea.DockArea import DockArea
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QPainter, QPaintEvent
|
||||
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.toolbar import (
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.toolbar import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
ModularToolBar,
|
||||
SeparatorAction,
|
||||
)
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DockAreaConfig(ConnectionConfig):
|
||||
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
|
||||
@@ -49,27 +42,23 @@ class DockAreaConfig(ConnectionConfig):
|
||||
|
||||
|
||||
class BECDockArea(BECWidget, QWidget):
|
||||
"""
|
||||
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"_get_all_rpc",
|
||||
"new",
|
||||
"show",
|
||||
"hide",
|
||||
"selected_device",
|
||||
"panels",
|
||||
"panel_list",
|
||||
"delete",
|
||||
"delete_all",
|
||||
"remove",
|
||||
"save_state",
|
||||
"remove_dock",
|
||||
"restore_state",
|
||||
"add_dock",
|
||||
"clear_all",
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"save_state",
|
||||
"restore_state",
|
||||
"_get_all_rpc",
|
||||
"temp_areas",
|
||||
"show",
|
||||
"hide",
|
||||
"delete",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -78,8 +67,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
config: DockAreaConfig | None = None,
|
||||
client=None,
|
||||
gui_id: str = None,
|
||||
object_name: str = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = DockAreaConfig(widget_class=self.__class__.__name__)
|
||||
@@ -87,47 +74,37 @@ class BECDockArea(BECWidget, QWidget):
|
||||
if isinstance(config, dict):
|
||||
config = DockAreaConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
object_name=object_name,
|
||||
client=client,
|
||||
gui_id=gui_id,
|
||||
config=config,
|
||||
**kwargs,
|
||||
)
|
||||
self._parent = parent # TODO probably not needed
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setSpacing(5)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._instructions_visible = True
|
||||
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dock_area = DockArea(parent=self)
|
||||
self.dock_area = DockArea()
|
||||
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",
|
||||
icon_name=BECWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
icon_name=BECMultiWaveformWidget.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
|
||||
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
|
||||
icon_name=BECMotorMapWidget.ICON_NAME,
|
||||
tooltip="Add Motor Map",
|
||||
filled=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -163,14 +140,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
),
|
||||
"sbb_monitor": MaterialIconAction(
|
||||
icon_name="train", tooltip="Add SBB Monitor", filled=True
|
||||
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -188,10 +159,10 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.dock_area)
|
||||
self.spacer = QWidget(parent=self)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
self.toolbar.addWidget(DarkModeButton(toolbar=True))
|
||||
self._hook_toolbar()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
@@ -200,49 +171,41 @@ class BECDockArea(BECWidget, QWidget):
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
|
||||
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
|
||||
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
|
||||
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
lambda: self.add_dock(widget="BECQueue", prefix="queue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
|
||||
)
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].setEnabled(False)
|
||||
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
# )
|
||||
self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
|
||||
)
|
||||
|
||||
# Icons
|
||||
@@ -250,14 +213,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
|
||||
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
# Run with RPC broadcast to namespace of all widgets
|
||||
with RPCRegister.delayed_broadcast():
|
||||
name = pascal_to_snake(widget_name)
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
|
||||
self.new(name=dock_name, widget=widget_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
|
||||
super().paintEvent(event)
|
||||
if self._instructions_visible:
|
||||
@@ -265,9 +220,20 @@ class BECDockArea(BECWidget, QWidget):
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignCenter,
|
||||
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
|
||||
)
|
||||
|
||||
@property
|
||||
def selected_device(self) -> str:
|
||||
gui_id = QApplication.instance().gui_id
|
||||
auto_update_config = self.client.connector.get(
|
||||
MessageEndpoints.gui_auto_update_config(gui_id)
|
||||
)
|
||||
try:
|
||||
return auto_update_config.selected_device
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def panels(self) -> dict[str, BECDock]:
|
||||
"""
|
||||
@@ -279,17 +245,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
@panels.setter
|
||||
def panels(self, value: dict[str, BECDock]):
|
||||
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
|
||||
|
||||
@property
|
||||
def panel_list(self) -> list[BECDock]:
|
||||
"""
|
||||
Get the docks in the dock area.
|
||||
|
||||
Returns:
|
||||
list: The docks in the dock area.
|
||||
"""
|
||||
return list(self.dock_area.docks.values())
|
||||
self.dock_area.docks = WeakValueDictionary(value)
|
||||
|
||||
@property
|
||||
def temp_areas(self) -> list:
|
||||
@@ -319,8 +275,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"""
|
||||
if state is None:
|
||||
state = self.config.docks_state
|
||||
if state is None:
|
||||
return
|
||||
self.dock_area.restoreState(state, missing=missing, extra=extra)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -335,17 +289,36 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.config.docks_state = last_state
|
||||
return last_state
|
||||
|
||||
def remove_dock(self, name: str):
|
||||
"""
|
||||
Remove a dock by name and ensure it is properly closed and cleaned up.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to remove.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(name, None)
|
||||
self.config.docks.pop(name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Dock with name {name} does not exist.")
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
def add_dock(
|
||||
self,
|
||||
name: str | None = None,
|
||||
widget: str | QWidget | None = None,
|
||||
widget_name: str | None = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
|
||||
name: str = None,
|
||||
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
|
||||
relative_to: BECDock | None = None,
|
||||
closable: bool = True,
|
||||
floating: bool = False,
|
||||
row: int | None = None,
|
||||
prefix: str = "dock",
|
||||
widget: str | QWidget | None = None,
|
||||
row: int = None,
|
||||
col: int = 0,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
@@ -355,11 +328,12 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock to be displayed and for further references. Has to be unique.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
|
||||
relative_to(BECDock): The dock to which the new dock should be added relative to.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floating(bool): Whether the dock is detached after creating.
|
||||
prefix(str): The prefix for the dock name if no name is provided.
|
||||
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
|
||||
row(int): The row of the added widget.
|
||||
col(int): The column of the added widget.
|
||||
rowspan(int): The rowspan of the added widget.
|
||||
@@ -368,30 +342,21 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Returns:
|
||||
BECDock: The created dock.
|
||||
"""
|
||||
dock_names = [
|
||||
dock.object_name for dock in self.panel_list
|
||||
] # pylint: disable=protected-access
|
||||
if name is not None: # Name is provided
|
||||
if name in dock_names:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for docks, but already exists in DockArea "
|
||||
f"with name: {self.object_name} and id {self.gui_id}."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
if name is None:
|
||||
name = WidgetContainerUtils.generate_unique_widget_id(
|
||||
container=self.dock_area.docks, prefix=prefix
|
||||
)
|
||||
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
if name in set(self.dock_area.docks.keys()):
|
||||
raise ValueError(f"Dock with name {name} already exists.")
|
||||
|
||||
dock = BECDock(
|
||||
parent=self,
|
||||
name=name, # this is dock name pyqtgraph property, this is displayed on label
|
||||
object_name=name, # this is a real qt object name passed to BECConnector
|
||||
parent_dock_area=self,
|
||||
closable=closable,
|
||||
)
|
||||
if position is None:
|
||||
position = "bottom"
|
||||
|
||||
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
|
||||
dock.config.position = position
|
||||
self.config.docks[dock.name()] = dock.config
|
||||
# The dock.name is equal to the name passed to BECDock
|
||||
self.config.docks[name] = dock.config
|
||||
|
||||
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
|
||||
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
@@ -400,11 +365,10 @@ class BECDockArea(BECWidget, QWidget):
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.show_title_bar()
|
||||
|
||||
if widget is not None:
|
||||
# Check if widget name exists.
|
||||
dock.new(
|
||||
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
|
||||
)
|
||||
if widget is not None and isinstance(widget, str):
|
||||
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
elif widget is not None and isinstance(widget, QWidget):
|
||||
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
|
||||
if (
|
||||
self._instructions_visible
|
||||
): # TODO still decide how initial instructions should be handled
|
||||
@@ -442,24 +406,49 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Remove a temporary area from the dock area.
|
||||
This is a patched method of pyqtgraph's removeTempArea
|
||||
"""
|
||||
if area not in self.dock_area.tempAreas:
|
||||
# FIXME add some context for the logging, I am not sure which object is passed.
|
||||
# It looks like a pyqtgraph.DockArea
|
||||
logger.info(f"Attempted to remove dock_area, but was not floating.")
|
||||
return
|
||||
self.dock_area.tempAreas.remove(area)
|
||||
area.window().close()
|
||||
area.window().deleteLater()
|
||||
|
||||
def clear_all(self):
|
||||
"""
|
||||
Close all docks and remove all temp areas.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock in dict(self.dock_area.docks).values():
|
||||
dock.remove()
|
||||
self.dock_area.docks.clear()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.dark_mode_button.close()
|
||||
self.dark_mode_button.deleteLater()
|
||||
self.clear_all()
|
||||
self.toolbar.close()
|
||||
self.toolbar.deleteLater()
|
||||
self.dock_area.close()
|
||||
self.dock_area.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.parent() is None:
|
||||
# we are at top-level (independent window)
|
||||
if self.isVisible():
|
||||
# we are visible => user clicked on [X]
|
||||
# (when closeEvent is called from shutdown procedure,
|
||||
# everything is hidden first)
|
||||
# so, let's ignore "close", and do hide instead
|
||||
event.ignore()
|
||||
self.setVisible(False)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close the dock area and cleanup.
|
||||
Has to be implemented to overwrite pyqtgraph event accept in Container close.
|
||||
"""
|
||||
self.cleanup()
|
||||
super().close()
|
||||
|
||||
def show(self):
|
||||
"""Show all windows including floating docks."""
|
||||
super().show()
|
||||
@@ -478,65 +467,18 @@ class BECDockArea(BECWidget, QWidget):
|
||||
continue
|
||||
docks.window().hide()
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""
|
||||
Delete all docks.
|
||||
"""
|
||||
self.attach_all()
|
||||
for dock_name in self.panels.keys():
|
||||
self.delete(dock_name)
|
||||
|
||||
def delete(self, dock_name: str):
|
||||
"""
|
||||
Delete a dock by name.
|
||||
|
||||
Args:
|
||||
dock_name(str): The name of the dock to delete.
|
||||
"""
|
||||
dock = self.dock_area.docks.pop(dock_name, None)
|
||||
self.config.docks.pop(dock_name, None)
|
||||
if dock:
|
||||
dock.close()
|
||||
dock.deleteLater()
|
||||
if len(self.dock_area.docks) <= 1:
|
||||
for dock in self.dock_area.docks.values():
|
||||
dock.hide_title_bar()
|
||||
else:
|
||||
raise ValueError(f"Dock with name {dock_name} does not exist.")
|
||||
# self._broadcast_update()
|
||||
|
||||
def remove(self) -> None:
|
||||
"""
|
||||
Remove the dock area. If the dock area is embedded in a BECMainWindow and
|
||||
is set as the central widget, the main window will be closed.
|
||||
"""
|
||||
parent = self.parent()
|
||||
if isinstance(parent, BECMainWindow):
|
||||
central_widget = parent.centralWidget()
|
||||
if central_widget is self:
|
||||
# Closing the parent will also close the dock area
|
||||
parent.close()
|
||||
return
|
||||
|
||||
self.close()
|
||||
def delete(self):
|
||||
self.hide()
|
||||
self.deleteLater()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
import sys
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("auto")
|
||||
dock_area = BECDockArea()
|
||||
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
||||
dock_1.new(widget="DarkModeButton")
|
||||
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
|
||||
dock_area.new(widget="DarkModeButton")
|
||||
dock_area.show()
|
||||
dock_area.setGeometry(100, 100, 800, 600)
|
||||
app.topLevelWidgets()
|
||||
WidgetHierarchy.print_becconnector_hierarchy_from_app()
|
||||
app.exec_()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
1
bec_widgets/widgets/containers/figure/__init__.py
Normal file
1
bec_widgets/widgets/containers/figure/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .figure import BECFigure, FigureConfig
|
||||
796
bec_widgets/widgets/containers/figure/figure.py
Normal file
796
bec_widgets/widgets/containers/figure/figure.py
Normal file
@@ -0,0 +1,796 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from typeguard import typechecked
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow, ImageConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import (
|
||||
BECMotorMap,
|
||||
MotorMapConfig,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
|
||||
BECMultiWaveform,
|
||||
BECMultiWaveformConfig,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import (
|
||||
BECWaveform,
|
||||
Waveform1DConfig,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FigureConfig(ConnectionConfig):
|
||||
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
|
||||
|
||||
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
|
||||
num_cols: int = Field(1, description="The number of columns in the figure widget.")
|
||||
num_rows: int = Field(1, description="The number of rows in the figure widget.")
|
||||
widgets: dict[str, Waveform1DConfig | ImageConfig | MotorMapConfig | SubplotConfig] = Field(
|
||||
{}, description="The list of widgets to be added to the figure widget."
|
||||
)
|
||||
|
||||
@field_validator("widgets", mode="before")
|
||||
@classmethod
|
||||
def validate_widgets(cls, v):
|
||||
"""Validate the widgets configuration."""
|
||||
widget_class_map = {
|
||||
"BECWaveform": Waveform1DConfig,
|
||||
"BECImageShow": ImageConfig,
|
||||
"BECMotorMap": MotorMapConfig,
|
||||
}
|
||||
validated_widgets = {}
|
||||
for key, widget_config in v.items():
|
||||
if "widget_class" not in widget_config:
|
||||
raise ValueError(f"Widget config for {key} does not contain 'widget_class'.")
|
||||
widget_class = widget_config["widget_class"]
|
||||
if widget_class not in widget_class_map:
|
||||
raise ValueError(f"Unknown widget_class '{widget_class}' for widget '{key}'.")
|
||||
config_class = widget_class_map[widget_class]
|
||||
validated_widgets[key] = config_class(**widget_config)
|
||||
return validated_widgets
|
||||
|
||||
|
||||
class WidgetHandler:
|
||||
"""Factory for creating and configuring BEC widgets for BECFigure."""
|
||||
|
||||
def __init__(self):
|
||||
self.widget_factory = {
|
||||
"BECPlotBase": (BECPlotBase, SubplotConfig),
|
||||
"BECWaveform": (BECWaveform, Waveform1DConfig),
|
||||
"BECImageShow": (BECImageShow, ImageConfig),
|
||||
"BECMotorMap": (BECMotorMap, MotorMapConfig),
|
||||
"BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
|
||||
}
|
||||
|
||||
def create_widget(
|
||||
self,
|
||||
widget_type: str,
|
||||
widget_id: str,
|
||||
parent_figure,
|
||||
parent_id: str,
|
||||
config: dict = None,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Create and configure a widget based on its type.
|
||||
|
||||
Args:
|
||||
widget_type (str): The type of the widget to create.
|
||||
widget_id (str): Unique identifier for the widget.
|
||||
parent_id (str): Identifier of the parent figure.
|
||||
config (dict, optional): Additional configuration for the widget.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECPlotBase: The created and configured widget instance.
|
||||
"""
|
||||
entry = self.widget_factory.get(widget_type)
|
||||
if not entry:
|
||||
raise ValueError(f"Unsupported widget type: {widget_type}")
|
||||
|
||||
widget_class, config_class = entry
|
||||
if config is not None and isinstance(config, config_class):
|
||||
config = config.model_dump()
|
||||
widget_config_dict = {
|
||||
"widget_class": widget_class.__name__,
|
||||
"parent_id": parent_id,
|
||||
"gui_id": widget_id,
|
||||
**(config if config is not None else {}),
|
||||
}
|
||||
widget_config = config_class(**widget_config_dict)
|
||||
widget = widget_class(
|
||||
config=widget_config, parent_figure=parent_figure, client=parent_figure.client
|
||||
)
|
||||
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
|
||||
return widget
|
||||
|
||||
|
||||
class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"_get_all_rpc",
|
||||
"axes",
|
||||
"widgets",
|
||||
"plot",
|
||||
"image",
|
||||
"motor_map",
|
||||
"remove",
|
||||
"change_layout",
|
||||
"change_theme",
|
||||
"export",
|
||||
"clear_all",
|
||||
"widget_list",
|
||||
]
|
||||
subplot_map = {
|
||||
"PlotBase": BECPlotBase,
|
||||
"BECWaveform": BECWaveform,
|
||||
"BECImageShow": BECImageShow,
|
||||
"BECMotorMap": BECMotorMap,
|
||||
"BECMultiWaveform": BECMultiWaveform,
|
||||
}
|
||||
widget_method_map = {
|
||||
"BECWaveform": "plot",
|
||||
"BECImageShow": "image",
|
||||
"BECMotorMap": "motor_map",
|
||||
"BECMultiWaveform": "multi_waveform",
|
||||
}
|
||||
|
||||
clean_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
config: Optional[FigureConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if config is None:
|
||||
config = FigureConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = FigureConfig(**config)
|
||||
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
pg.GraphicsLayoutWidget.__init__(self, parent)
|
||||
|
||||
self.widget_handler = WidgetHandler()
|
||||
|
||||
# Widget container to reference widgets by 'widget_id'
|
||||
self._widgets = defaultdict(dict)
|
||||
|
||||
# Container to keep track of the grid
|
||||
self.grid = []
|
||||
# Create config and apply it
|
||||
self.apply_config(config)
|
||||
|
||||
def __getitem__(self, key: tuple | str):
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
return self.axes(*key)
|
||||
if isinstance(key, str):
|
||||
widget = self._widgets.get(key)
|
||||
if widget is None:
|
||||
raise KeyError(f"No widget with ID {key}")
|
||||
return self._widgets.get(key)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
|
||||
)
|
||||
|
||||
def apply_config(self, config: dict | FigureConfig): # ,generate_new_id: bool = False):
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = FigureConfig(**config)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Error in applying config: {e}")
|
||||
return
|
||||
self.config = config
|
||||
|
||||
# widget_config has to be reset for not have each widget config twice when added to the figure
|
||||
widget_configs = list(self.config.widgets.values())
|
||||
self.config.widgets = {}
|
||||
for widget_config in widget_configs:
|
||||
getattr(self, self.widget_method_map[widget_config.widget_class])(
|
||||
config=widget_config.model_dump(), row=widget_config.row, col=widget_config.col
|
||||
)
|
||||
|
||||
@property
|
||||
def widget_list(self) -> list[BECPlotBase]:
|
||||
"""
|
||||
Access all widget in BECFigure as a list
|
||||
Returns:
|
||||
list[BECPlotBase]: List of all widgets in the figure.
|
||||
"""
|
||||
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
|
||||
return axes
|
||||
|
||||
@widget_list.setter
|
||||
def widget_list(self, value: list[BECPlotBase]):
|
||||
"""
|
||||
Access all widget in BECFigure as a list
|
||||
Returns:
|
||||
list[BECPlotBase]: List of all widgets in the figure.
|
||||
"""
|
||||
self._axes = value
|
||||
|
||||
@property
|
||||
def widgets(self) -> dict:
|
||||
"""
|
||||
All widgets within the figure with gui ids as keys.
|
||||
Returns:
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
return self._widgets
|
||||
|
||||
@widgets.setter
|
||||
def widgets(self, value: dict):
|
||||
"""
|
||||
All widgets within the figure with gui ids as keys.
|
||||
Returns:
|
||||
dict: All widgets within the figure.
|
||||
"""
|
||||
self._widgets = value
|
||||
|
||||
def export(self):
|
||||
"""Export the plot widget."""
|
||||
try:
|
||||
plot_item = self.widget_list[0]
|
||||
except Exception as exc:
|
||||
raise ValueError("No plot widget available to export.") from exc
|
||||
|
||||
scene = plot_item.scene()
|
||||
scene.contextMenuItem = plot_item
|
||||
scene.showExportDialog()
|
||||
|
||||
@typechecked
|
||||
def plot(
|
||||
self,
|
||||
arg1: list | np.ndarray | str | None = None,
|
||||
y: list | np.ndarray | None = None,
|
||||
x: list | np.ndarray | None = None,
|
||||
x_name: str | None = None,
|
||||
y_name: str | None = None,
|
||||
z_name: str | None = None,
|
||||
x_entry: str | None = None,
|
||||
y_entry: str | None = None,
|
||||
z_entry: str | None = None,
|
||||
color: str | None = None,
|
||||
color_map_z: str | None = "magma",
|
||||
label: str | None = None,
|
||||
validate: bool = True,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
dap: str | None = None,
|
||||
config: dict | None = None, # TODO make logic more transparent
|
||||
**axis_kwargs,
|
||||
) -> BECWaveform:
|
||||
"""
|
||||
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
|
||||
|
||||
Args:
|
||||
arg1(list | np.ndarray | str | None): First argument which can be x data, y data, or y_name.
|
||||
y(list | np.ndarray): Custom y data to plot.
|
||||
x(list | np.ndarray): Custom x data to plot.
|
||||
x_name(str): The name of the device for the x-axis.
|
||||
y_name(str): The name of the device for the y-axis.
|
||||
z_name(str): The name of the device for the z-axis.
|
||||
x_entry(str): The name of the entry for the x-axis.
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
z_entry(str): The name of the entry for the z-axis.
|
||||
color(str): The color of the curve.
|
||||
color_map_z(str): The color map to use for the z-axis.
|
||||
label(str): The label of the curve.
|
||||
validate(bool): If True, validate the device names and entries.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
dap(str): The DAP model to use for the curve.
|
||||
config(dict): Recreates the whole BECWaveform widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECWaveform: The waveform plot widget.
|
||||
"""
|
||||
waveform = self.subplot_factory(
|
||||
widget_type="BECWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return waveform
|
||||
|
||||
if arg1 is not None or y_name is not None or (y is not None and x is not None):
|
||||
waveform.plot(
|
||||
arg1=arg1,
|
||||
y=y,
|
||||
x=x,
|
||||
x_name=x_name,
|
||||
y_name=y_name,
|
||||
z_name=z_name,
|
||||
x_entry=x_entry,
|
||||
y_entry=y_entry,
|
||||
z_entry=z_entry,
|
||||
color=color,
|
||||
color_map_z=color_map_z,
|
||||
label=label,
|
||||
validate=validate,
|
||||
dap=dap,
|
||||
)
|
||||
return waveform
|
||||
|
||||
def _init_image(
|
||||
self,
|
||||
image,
|
||||
monitor: str = None,
|
||||
monitor_type: Literal["1d", "2d"] = "2d",
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Configure the image based on the provided parameters.
|
||||
|
||||
Args:
|
||||
image (BECImageShow): The image to configure.
|
||||
monitor (str): The name of the monitor to display.
|
||||
color_bar (Literal["simple","full"]): The type of color bar to display.
|
||||
color_map (str): The color map to use for the image.
|
||||
data (np.ndarray): Custom data to display.
|
||||
"""
|
||||
if monitor is not None and data is None:
|
||||
image.image(
|
||||
monitor=monitor,
|
||||
monitor_type=monitor_type,
|
||||
color_map=color_map,
|
||||
vrange=vrange,
|
||||
color_bar=color_bar,
|
||||
)
|
||||
elif data is not None and monitor is None:
|
||||
image.add_custom_image(
|
||||
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
|
||||
)
|
||||
elif data is None and monitor is None:
|
||||
# Setting appearance
|
||||
if vrange is not None:
|
||||
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
|
||||
if color_map is not None:
|
||||
image.set_color_map(color_map)
|
||||
else:
|
||||
raise ValueError("Invalid input. Provide either monitor name or custom data.")
|
||||
return image
|
||||
|
||||
def image(
|
||||
self,
|
||||
monitor: str = None,
|
||||
monitor_type: Literal["1d", "2d"] = "2d",
|
||||
color_bar: Literal["simple", "full"] = "full",
|
||||
color_map: str = "magma",
|
||||
data: np.ndarray = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
) -> BECImageShow:
|
||||
"""
|
||||
Add an image to the figure. Always access the first image widget in the figure.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECImageShow: The image widget.
|
||||
"""
|
||||
|
||||
image = self.subplot_factory(
|
||||
widget_type="BECImageShow", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return image
|
||||
|
||||
image = self._init_image(
|
||||
image=image,
|
||||
monitor=monitor,
|
||||
monitor_type=monitor_type,
|
||||
color_bar=color_bar,
|
||||
color_map=color_map,
|
||||
data=data,
|
||||
vrange=vrange,
|
||||
)
|
||||
return image
|
||||
|
||||
def motor_map(
|
||||
self,
|
||||
motor_x: str = None,
|
||||
motor_y: str = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
) -> BECMotorMap:
|
||||
"""
|
||||
Add a motor map to the figure. Always access the first motor map widget in the figure.
|
||||
|
||||
Args:
|
||||
motor_x(str): The name of the motor for the X axis.
|
||||
motor_y(str): The name of the motor for the Y axis.
|
||||
new(bool): If True, create a new plot instead of using the first plot.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Recreates the whole BECImageShow widget from provided configuration.
|
||||
**axis_kwargs: Additional axis properties to set on the widget after creation.
|
||||
|
||||
Returns:
|
||||
BECMotorMap: The motor map widget.
|
||||
"""
|
||||
motor_map = self.subplot_factory(
|
||||
widget_type="BECMotorMap", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return motor_map
|
||||
|
||||
if motor_x is not None and motor_y is not None:
|
||||
motor_map.change_motors(motor_x, motor_y)
|
||||
|
||||
return motor_map
|
||||
|
||||
def multi_waveform(
|
||||
self,
|
||||
monitor: str = None,
|
||||
new: bool = False,
|
||||
row: int | None = None,
|
||||
col: int | None = None,
|
||||
config: dict | None = None,
|
||||
**axis_kwargs,
|
||||
):
|
||||
multi_waveform = self.subplot_factory(
|
||||
widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
|
||||
)
|
||||
if config is not None:
|
||||
return multi_waveform
|
||||
multi_waveform.set_monitor(monitor)
|
||||
return multi_waveform
|
||||
|
||||
def subplot_factory(
|
||||
self,
|
||||
widget_type: Literal[
|
||||
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
|
||||
] = "BECPlotBase",
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
new: bool = False,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
# Case 1 - config provided, new plot, possible to define coordinates
|
||||
if config is not None:
|
||||
widget_cls = config["widget_class"]
|
||||
if widget_cls != widget_type:
|
||||
raise ValueError(
|
||||
f"Widget type '{widget_type}' does not match the provided configuration ({widget_cls})."
|
||||
)
|
||||
widget = self.add_widget(
|
||||
widget_type=widget_type, config=config, row=row, col=col, **axis_kwargs
|
||||
)
|
||||
return widget
|
||||
|
||||
# Case 2 - find first plot or create first plot if no plot available, no config provided, no coordinates
|
||||
if new is False and (row is None or col is None):
|
||||
widget = WidgetContainerUtils.find_first_widget_by_class(
|
||||
self._widgets, self.subplot_map[widget_type], can_fail=True
|
||||
)
|
||||
if widget is not None:
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
else:
|
||||
widget = self.add_widget(widget_type=widget_type, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
# Case 3 - modifying existing plot wit coordinates provided
|
||||
if new is False and (row is not None and col is not None):
|
||||
try:
|
||||
widget = self.axes(row, col)
|
||||
except ValueError:
|
||||
widget = None
|
||||
if widget is not None:
|
||||
if axis_kwargs:
|
||||
widget.set(**axis_kwargs)
|
||||
else:
|
||||
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
# Case 4 - no previous plot or new plot, no config provided, possible to define coordinates
|
||||
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
|
||||
return widget
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget_type: Literal[
|
||||
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
|
||||
] = "BECPlotBase",
|
||||
widget_id: str = None,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
config=None,
|
||||
**axis_kwargs,
|
||||
) -> BECPlotBase:
|
||||
"""
|
||||
Add a widget to the figure at the specified position.
|
||||
|
||||
Args:
|
||||
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
|
||||
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
|
||||
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
|
||||
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
|
||||
config(dict): Additional configuration for the widget.
|
||||
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
|
||||
"""
|
||||
if not widget_id:
|
||||
widget_id = str(uuid.uuid4())
|
||||
if widget_id in self._widgets:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
|
||||
|
||||
# Check if position is occupied
|
||||
if row is not None and col is not None:
|
||||
if self.getItem(row, col):
|
||||
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
|
||||
else:
|
||||
row, col = self._find_next_empty_position()
|
||||
|
||||
widget = self.widget_handler.create_widget(
|
||||
widget_type=widget_type,
|
||||
widget_id=widget_id,
|
||||
parent_figure=self,
|
||||
parent_id=self.gui_id,
|
||||
config=config,
|
||||
**axis_kwargs,
|
||||
)
|
||||
widget.set_gui_id(widget_id)
|
||||
widget.config.row = row
|
||||
widget.config.col = col
|
||||
|
||||
# Add widget to the figure
|
||||
self.addItem(widget, row=row, col=col)
|
||||
|
||||
# Update num_cols and num_rows based on the added widget
|
||||
self.config.num_rows = max(self.config.num_rows, row + 1)
|
||||
self.config.num_cols = max(self.config.num_cols, col + 1)
|
||||
|
||||
# Saving config for future referencing
|
||||
|
||||
self.config.widgets[widget_id] = widget.config
|
||||
self._widgets[widget_id] = widget
|
||||
|
||||
# Reflect the grid coordinates
|
||||
self._change_grid(widget_id, row, col)
|
||||
|
||||
return widget
|
||||
|
||||
def remove(
|
||||
self,
|
||||
row: int = None,
|
||||
col: int = None,
|
||||
widget_id: str = None,
|
||||
coordinates: tuple[int, int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
|
||||
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
|
||||
"""
|
||||
if widget_id:
|
||||
self._remove_by_id(widget_id)
|
||||
elif row is not None and col is not None:
|
||||
self._remove_by_coordinates(row, col)
|
||||
elif coordinates:
|
||||
self._remove_by_coordinates(*coordinates)
|
||||
else:
|
||||
raise ValueError("Must provide either widget_id or coordinates for removal.")
|
||||
|
||||
def change_theme(self, theme: Literal["dark", "light"]) -> None:
|
||||
"""
|
||||
Change the theme of the figure widget.
|
||||
|
||||
Args:
|
||||
theme(Literal["dark","light"]): The theme to set for the figure widget.
|
||||
"""
|
||||
self.config.theme = theme
|
||||
apply_theme(theme)
|
||||
for plot in self.widget_list:
|
||||
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
|
||||
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
|
||||
if plot.plot_item.titleLabel.text:
|
||||
plot.set_title(plot.plot_item.titleLabel.text)
|
||||
plot.set_legend_label_size()
|
||||
|
||||
def _remove_by_coordinates(self, row: int, col: int) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its coordinates.
|
||||
|
||||
Args:
|
||||
row(int): The row coordinate of the widget to remove.
|
||||
col(int): The column coordinate of the widget to remove.
|
||||
"""
|
||||
widget = self.axes(row, col)
|
||||
if widget:
|
||||
widget_id = widget.config.gui_id
|
||||
if widget_id in self._widgets:
|
||||
self._remove_by_id(widget_id)
|
||||
|
||||
def _remove_by_id(self, widget_id: str) -> None:
|
||||
"""
|
||||
Remove a widget from the figure by its unique identifier.
|
||||
|
||||
Args:
|
||||
widget_id(str): The unique identifier of the widget to remove.
|
||||
"""
|
||||
if widget_id in self._widgets:
|
||||
widget = self._widgets.pop(widget_id)
|
||||
widget.cleanup_pyqtgraph()
|
||||
widget.cleanup()
|
||||
self.removeItem(widget)
|
||||
self.grid[widget.config.row][widget.config.col] = None
|
||||
self._reindex_grid()
|
||||
if widget_id in self.config.widgets:
|
||||
self.config.widgets.pop(widget_id)
|
||||
widget.deleteLater()
|
||||
else:
|
||||
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
|
||||
|
||||
def axes(self, row: int, col: int) -> BECPlotBase:
|
||||
"""
|
||||
Get widget by its coordinates in the figure.
|
||||
|
||||
Args:
|
||||
row(int): the row coordinate
|
||||
col(int): the column coordinate
|
||||
|
||||
Returns:
|
||||
BECPlotBase: the widget at the given coordinates
|
||||
"""
|
||||
widget = self.getItem(row, col)
|
||||
if widget is None:
|
||||
raise ValueError(f"No widget at coordinates ({row}, {col})")
|
||||
return widget
|
||||
|
||||
def _find_next_empty_position(self):
|
||||
"""Find the next empty position (new row) in the figure."""
|
||||
row, col = 0, 0
|
||||
while self.getItem(row, col):
|
||||
row += 1
|
||||
return row, col
|
||||
|
||||
def _change_grid(self, widget_id: str, row: int, col: int):
|
||||
"""
|
||||
Change the grid to reflect the new position of the widget.
|
||||
|
||||
Args:
|
||||
widget_id(str): The unique identifier of the widget.
|
||||
row(int): The new row coordinate of the widget in the figure.
|
||||
col(int): The new column coordinate of the widget in the figure.
|
||||
"""
|
||||
while len(self.grid) <= row:
|
||||
self.grid.append([])
|
||||
row = self.grid[row]
|
||||
while len(row) <= col:
|
||||
row.append(None)
|
||||
row[col] = widget_id
|
||||
|
||||
def _reindex_grid(self):
|
||||
"""Reindex the grid to remove empty rows and columns."""
|
||||
new_grid = []
|
||||
for row in self.grid:
|
||||
new_row = [widget for widget in row if widget is not None]
|
||||
if new_row:
|
||||
new_grid.append(new_row)
|
||||
#
|
||||
# Update the config of each object to reflect its new position
|
||||
for row_idx, row in enumerate(new_grid):
|
||||
for col_idx, widget in enumerate(row):
|
||||
self._widgets[widget].config.row, self._widgets[widget].config.col = (
|
||||
row_idx,
|
||||
col_idx,
|
||||
)
|
||||
|
||||
self.grid = new_grid
|
||||
self._replot_layout()
|
||||
|
||||
def _replot_layout(self):
|
||||
"""Replot the layout based on the current grid configuration."""
|
||||
self.clear()
|
||||
for row_idx, row in enumerate(self.grid):
|
||||
for col_idx, widget in enumerate(row):
|
||||
self.addItem(self._widgets[widget], row=row_idx, col=col_idx)
|
||||
|
||||
def change_layout(self, max_columns=None, max_rows=None):
|
||||
"""
|
||||
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
|
||||
If both max_columns and max_rows are provided, max_rows is ignored.
|
||||
|
||||
Args:
|
||||
max_columns (Optional[int]): The new maximum number of columns in the figure.
|
||||
max_rows (Optional[int]): The new maximum number of rows in the figure.
|
||||
"""
|
||||
# Calculate total number of widgets
|
||||
total_widgets = len(self._widgets)
|
||||
|
||||
if max_columns:
|
||||
# Calculate the required number of rows based on max_columns
|
||||
required_rows = (total_widgets + max_columns - 1) // max_columns
|
||||
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
|
||||
elif max_rows:
|
||||
# Calculate the required number of columns based on max_rows
|
||||
required_columns = (total_widgets + max_rows - 1) // max_rows
|
||||
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
|
||||
else:
|
||||
# If neither max_columns nor max_rows is specified, just return without changing the layout
|
||||
return
|
||||
|
||||
# Populate the new grid with widgets' IDs
|
||||
current_idx = 0
|
||||
for widget_id in self._widgets:
|
||||
row = current_idx // len(new_grid[0])
|
||||
col = current_idx % len(new_grid[0])
|
||||
new_grid[row][col] = widget_id
|
||||
current_idx += 1
|
||||
|
||||
self.config.num_rows = row
|
||||
self.config.num_cols = col
|
||||
|
||||
# Update widgets' positions and replot them according to the new grid
|
||||
self.grid = new_grid
|
||||
self._reindex_grid() # This method should be updated to handle reshuffling correctly
|
||||
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all widgets from the figure and reset to default state"""
|
||||
for widget in list(self._widgets.values()):
|
||||
widget.remove()
|
||||
self._widgets.clear()
|
||||
self.grid = []
|
||||
theme = self.config.theme
|
||||
self.config = FigureConfig(
|
||||
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
|
||||
)
|
||||
|
||||
def cleanup_pyqtgraph_all_widgets(self):
|
||||
"""Clean up the pyqtgraph widget."""
|
||||
for widget in self.widget_list:
|
||||
widget.cleanup_pyqtgraph()
|
||||
|
||||
def cleanup(self):
|
||||
"""Close the figure widget."""
|
||||
self.cleanup_pyqtgraph_all_widgets()
|
||||
91
bec_widgets/widgets/containers/figure/plots/axis_settings.py
Normal file
91
bec_widgets/widgets/containers/figure/plots/axis_settings.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QVBoxLayout
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.qt_utils.settings_dialog import SettingWidget
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
class AxisSettings(SettingWidget):
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader().load_ui(os.path.join(current_path, "axis_settings.ui"), self)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.addWidget(self.ui)
|
||||
|
||||
# Hardcoded values for best appearance
|
||||
self.setMinimumHeight(280)
|
||||
self.setMaximumHeight(280)
|
||||
self.resize(380, 280)
|
||||
|
||||
@Slot(dict)
|
||||
def display_current_settings(self, axis_config: dict):
|
||||
|
||||
if axis_config == {}:
|
||||
return
|
||||
|
||||
# Top Box
|
||||
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
|
||||
self.ui.switch_outer_axes.checked = axis_config["outer_axes"]
|
||||
|
||||
# X Axis Box
|
||||
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
|
||||
WidgetIO.set_value(self.ui.x_scale, axis_config["x_scale"])
|
||||
WidgetIO.set_value(self.ui.x_grid, axis_config["x_grid"])
|
||||
if axis_config["x_lim"] is not None:
|
||||
WidgetIO.check_and_adjust_limits(self.ui.x_min, axis_config["x_lim"][0])
|
||||
WidgetIO.check_and_adjust_limits(self.ui.x_max, axis_config["x_lim"][1])
|
||||
WidgetIO.set_value(self.ui.x_min, axis_config["x_lim"][0])
|
||||
WidgetIO.set_value(self.ui.x_max, axis_config["x_lim"][1])
|
||||
if axis_config["x_lim"] is None:
|
||||
x_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[0]
|
||||
WidgetIO.set_value(self.ui.x_min, x_range[0])
|
||||
WidgetIO.set_value(self.ui.x_max, x_range[1])
|
||||
|
||||
# Y Axis Box
|
||||
WidgetIO.set_value(self.ui.y_label, axis_config["y_label"])
|
||||
WidgetIO.set_value(self.ui.y_scale, axis_config["y_scale"])
|
||||
WidgetIO.set_value(self.ui.y_grid, axis_config["y_grid"])
|
||||
if axis_config["y_lim"] is not None:
|
||||
WidgetIO.check_and_adjust_limits(self.ui.y_min, axis_config["y_lim"][0])
|
||||
WidgetIO.check_and_adjust_limits(self.ui.y_max, axis_config["y_lim"][1])
|
||||
WidgetIO.set_value(self.ui.y_min, axis_config["y_lim"][0])
|
||||
WidgetIO.set_value(self.ui.y_max, axis_config["y_lim"][1])
|
||||
if axis_config["y_lim"] is None:
|
||||
y_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[1]
|
||||
WidgetIO.set_value(self.ui.y_min, y_range[0])
|
||||
WidgetIO.set_value(self.ui.y_max, y_range[1])
|
||||
|
||||
@Slot()
|
||||
def accept_changes(self):
|
||||
title = WidgetIO.get_value(self.ui.plot_title)
|
||||
outer_axes = self.ui.switch_outer_axes.checked
|
||||
|
||||
# X Axis
|
||||
x_label = WidgetIO.get_value(self.ui.x_label)
|
||||
x_scale = self.ui.x_scale.currentText()
|
||||
x_grid = WidgetIO.get_value(self.ui.x_grid)
|
||||
x_lim = (WidgetIO.get_value(self.ui.x_min), WidgetIO.get_value(self.ui.x_max))
|
||||
|
||||
# Y Axis
|
||||
y_label = WidgetIO.get_value(self.ui.y_label)
|
||||
y_scale = self.ui.y_scale.currentText()
|
||||
y_grid = WidgetIO.get_value(self.ui.y_grid)
|
||||
y_lim = (WidgetIO.get_value(self.ui.y_min), WidgetIO.get_value(self.ui.y_max))
|
||||
|
||||
self.target_widget.set(
|
||||
title=title,
|
||||
x_label=x_label,
|
||||
x_scale=x_scale,
|
||||
x_lim=x_lim,
|
||||
y_label=y_label,
|
||||
y_scale=y_scale,
|
||||
y_lim=y_lim,
|
||||
)
|
||||
self.target_widget.set_grid(x_grid, y_grid)
|
||||
self.target_widget.set_outer_axes(outer_axes)
|
||||
256
bec_widgets/widgets/containers/figure/plots/axis_settings.ui
Normal file
256
bec_widgets/widgets/containers/figure/plots/axis_settings.ui
Normal file
@@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>427</width>
|
||||
<height>270</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>250</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>278</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<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="plot_title"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
<string>Y Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="y_scale">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="y_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="y_label"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="y_scale_label">
|
||||
<property name="text">
|
||||
<string>Scale</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="y_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="y_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QCheckBox" name="y_grid">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="y_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Scale</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</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="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-9999.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>9999.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="x_scale">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>log</string>
|
||||
</property>
|
||||
</item>
|
||||
</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="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QCheckBox" name="x_grid">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="switch_outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
772
bec_widgets/widgets/containers/figure/plots/image/image.py
Normal file
772
bec_widgets/widgets/containers/figure/plots/image/image.py
Normal file
@@ -0,0 +1,772 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError
|
||||
from qtpy.QtCore import QThread, Slot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
# from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.utils import EntryValidator
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_item import (
|
||||
BECImageItem,
|
||||
ImageItemConfig,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
|
||||
ImageProcessor,
|
||||
ImageStats,
|
||||
ProcessorWorker,
|
||||
)
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ImageConfig(SubplotConfig):
|
||||
images: dict[str, ImageItemConfig] = Field(
|
||||
{},
|
||||
description="The configuration of the images. The key is the name of the image (source).",
|
||||
)
|
||||
|
||||
|
||||
class BECImageShow(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"add_image_by_config",
|
||||
"image",
|
||||
"add_custom_image",
|
||||
"set_vrange",
|
||||
"set_color_map",
|
||||
"set_autorange",
|
||||
"set_autorange_mode",
|
||||
"set_monitor",
|
||||
"set_processing",
|
||||
"set_image_properties",
|
||||
"set_fft",
|
||||
"set_log",
|
||||
"set_rotation",
|
||||
"set_transpose",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"remove",
|
||||
"images",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[ImageConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
single_image: bool = True,
|
||||
):
|
||||
if config is None:
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.single_image = single_image
|
||||
self.image_type = "device_monitor_2d"
|
||||
self.scan_id = None
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
self._images = defaultdict(dict)
|
||||
self.apply_config(self.config)
|
||||
self.processor = ImageProcessor()
|
||||
self.use_threading = False # TODO WILL be moved to the init method and to figure method
|
||||
|
||||
def _create_thread_worker(self, device: str, image: np.ndarray):
|
||||
thread = QThread()
|
||||
worker = ProcessorWorker(self.processor)
|
||||
worker.moveToThread(thread)
|
||||
|
||||
# Connect signals and slots
|
||||
thread.started.connect(lambda: worker.process_image(device, image))
|
||||
worker.processed.connect(self.update_image)
|
||||
worker.stats.connect(self.update_vrange)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(thread.wait)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
thread.start()
|
||||
|
||||
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
|
||||
"""
|
||||
Find the image item by its gui_id.
|
||||
|
||||
Args:
|
||||
item_id(str): The gui_id of the widget.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The widget with the given gui_id.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for key, value in images.items():
|
||||
if key == item_id and isinstance(value, BECImageItem):
|
||||
return value
|
||||
elif isinstance(value, dict):
|
||||
result = self.find_image_by_monitor(item_id)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
def apply_config(self, config: dict | SubplotConfig):
|
||||
"""
|
||||
Apply the configuration to the 1D waveform widget.
|
||||
|
||||
Args:
|
||||
config(dict|SubplotConfig): Configuration settings.
|
||||
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = ImageConfig(**config)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Validation error when applying config to BECImageShow: {e}")
|
||||
return
|
||||
self.config = config
|
||||
self.plot_item.clear()
|
||||
|
||||
self.apply_axis_config()
|
||||
self._images = defaultdict(dict)
|
||||
|
||||
for image_id, image_config in config.images.items():
|
||||
self.add_image_by_config(image_config)
|
||||
|
||||
def change_gui_id(self, new_gui_id: str):
|
||||
"""
|
||||
Change the GUI ID of the image widget and update the parent_id in all associated curves.
|
||||
|
||||
Args:
|
||||
new_gui_id (str): The new GUI ID to be set for the image widget.
|
||||
"""
|
||||
self.gui_id = new_gui_id
|
||||
self.config.gui_id = new_gui_id
|
||||
|
||||
for source, images in self._images.items():
|
||||
for id, image_item in images.items():
|
||||
image_item.config.parent_id = new_gui_id
|
||||
|
||||
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
|
||||
"""
|
||||
Add an image to the widget by configuration.
|
||||
|
||||
Args:
|
||||
config(ImageItemConfig|dict): The configuration of the image.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The image object.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = ImageItemConfig(**config)
|
||||
config.parent_id = self.gui_id
|
||||
name = config.monitor if config.monitor is not None else config.gui_id
|
||||
image = self._add_image_object(source=config.source, name=name, config=config)
|
||||
return image
|
||||
|
||||
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
|
||||
"""
|
||||
Get the configuration of the image.
|
||||
|
||||
Args:
|
||||
image_id(str): The ID of the image.
|
||||
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
|
||||
|
||||
Returns:
|
||||
ImageItemConfig|dict: The configuration of the image.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for id, image in images.items():
|
||||
if id == image_id:
|
||||
if dict_output:
|
||||
return image.config.dict()
|
||||
else:
|
||||
return image.config # TODO check if this works
|
||||
|
||||
@property
|
||||
def images(self) -> list[BECImageItem]:
|
||||
"""
|
||||
Get the list of images.
|
||||
Returns:
|
||||
list[BECImageItem]: The list of images.
|
||||
"""
|
||||
images = []
|
||||
for source, images_dict in self._images.items():
|
||||
for id, image in images_dict.items():
|
||||
images.append(image)
|
||||
return images
|
||||
|
||||
@images.setter
|
||||
def images(self, value: dict[str, dict[str, BECImageItem]]):
|
||||
"""
|
||||
Set the images from a dictionary.
|
||||
|
||||
Args:
|
||||
value (dict[str, dict[str, BECImageItem]]): The images to set, organized by source and id.
|
||||
"""
|
||||
self._images = value
|
||||
|
||||
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
|
||||
"""
|
||||
Get all images.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, BECImageItem]]: The dictionary of images.
|
||||
"""
|
||||
return self._images
|
||||
|
||||
def image(
|
||||
self,
|
||||
monitor: str,
|
||||
monitor_type: Literal["1d", "2d"] = "2d",
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
) -> BECImageItem:
|
||||
"""
|
||||
Add an image to the figure. Always access the first image widget in the figure.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor to display.
|
||||
monitor_type(Literal["1d","2d"]): The type of monitor to display.
|
||||
color_bar(Literal["simple","full"]): The type of color bar to display.
|
||||
color_map(str): The color map to use for the image.
|
||||
data(np.ndarray): Custom data to display.
|
||||
vrange(tuple[float, float]): The range of values to display.
|
||||
|
||||
Returns:
|
||||
BECImageItem: The image item.
|
||||
"""
|
||||
if monitor_type == "1d":
|
||||
image_source = "device_monitor_1d"
|
||||
self.image_type = "device_monitor_1d"
|
||||
elif monitor_type == "2d":
|
||||
image_source = "device_monitor_2d"
|
||||
self.image_type = "device_monitor_2d"
|
||||
|
||||
image_exits = self._check_image_id(monitor, self._images)
|
||||
if image_exits:
|
||||
# raise ValueError(
|
||||
# f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
|
||||
# )
|
||||
return
|
||||
|
||||
# monitor = self.entry_validator.validate_monitor(monitor)
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
parent_id=self.gui_id,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
source=image_source,
|
||||
monitor=monitor,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
|
||||
return image
|
||||
|
||||
def add_custom_image(
|
||||
self,
|
||||
name: str,
|
||||
data: Optional[np.ndarray] = None,
|
||||
color_map: Optional[str] = "magma",
|
||||
color_bar: Optional[Literal["simple", "full"]] = "full",
|
||||
downsample: Optional[bool] = True,
|
||||
opacity: Optional[float] = 1.0,
|
||||
vrange: Optional[tuple[int, int]] = None,
|
||||
# post_processing: Optional[PostProcessingConfig] = None,
|
||||
**kwargs,
|
||||
):
|
||||
image_source = "custom"
|
||||
|
||||
image_exits = self._check_image_id(name, self._images)
|
||||
if image_exits:
|
||||
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
|
||||
|
||||
image_config = ImageItemConfig(
|
||||
widget_class="BECImageItem",
|
||||
parent_id=self.gui_id,
|
||||
monitor=name,
|
||||
color_map=color_map,
|
||||
color_bar=color_bar,
|
||||
downsample=downsample,
|
||||
opacity=opacity,
|
||||
vrange=vrange,
|
||||
# post_processing=post_processing,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
image = self._add_image_object(
|
||||
source=image_source, name=name, config=image_config, data=data
|
||||
)
|
||||
return image
|
||||
|
||||
def apply_setting_to_images(
|
||||
self, setting_method_name: str, args: list, kwargs: dict, image_id: str = None
|
||||
):
|
||||
"""
|
||||
Apply a setting to all images or a specific image by its ID.
|
||||
|
||||
Args:
|
||||
setting_method_name (str): The name of the method to apply (e.g., 'set_color_map').
|
||||
args (list): Positional arguments for the setting method.
|
||||
kwargs (dict): Keyword arguments for the setting method.
|
||||
image_id (str, optional): The ID of the specific image to apply the setting to. If None, applies to all images.
|
||||
"""
|
||||
if image_id:
|
||||
image = self.find_image_by_monitor(image_id)
|
||||
if image:
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
else:
|
||||
for source, images in self._images.items():
|
||||
for _, image in images.items():
|
||||
getattr(image, setting_method_name)(*args, **kwargs)
|
||||
self.refresh_image()
|
||||
|
||||
def set_vrange(self, vmin: float, vmax: float, name: str = None):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
If name is not specified, then set vrange for all images.
|
||||
|
||||
Args:
|
||||
vmin(float): Minimum value of the color bar.
|
||||
vmax(float): Maximum value of the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_vrange", args=[vmin, vmax], kwargs={}, image_id=name)
|
||||
|
||||
def set_color_map(self, cmap: str, name: str = None):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
If name is not specified, then set color map for all images.
|
||||
|
||||
Args:
|
||||
cmap(str): The color map of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_color_map", args=[cmap], kwargs={}, image_id=name)
|
||||
|
||||
def set_autorange(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the autoscale of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to autoscale the color bar.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None):
|
||||
"""
|
||||
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
|
||||
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
|
||||
|
||||
Args:
|
||||
mode(str): The autoscale mode of the image.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name)
|
||||
|
||||
def set_monitor(self, monitor: str, name: str = None):
|
||||
"""
|
||||
Set the monitor of the image.
|
||||
If name is not specified, then set monitor for all images.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_monitor", args=[monitor], kwargs={}, image_id=name)
|
||||
|
||||
def set_processing(self, name: str = None, **kwargs):
|
||||
"""
|
||||
Set the post processing of the image.
|
||||
If name is not specified, then set post processing for all images.
|
||||
|
||||
Args:
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- fft: bool
|
||||
- log: bool
|
||||
- rot: int
|
||||
- transpose: bool
|
||||
"""
|
||||
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
|
||||
|
||||
def set_image_properties(self, name: str = None, **kwargs):
|
||||
"""
|
||||
Set the properties of the image.
|
||||
|
||||
Args:
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
Possible properties:
|
||||
- downsample: bool
|
||||
- color_map: str
|
||||
- monitor: str
|
||||
- opacity: float
|
||||
- vrange: tuple[int,int]
|
||||
- fft: bool
|
||||
- log: bool
|
||||
- rot: int
|
||||
- transpose: bool
|
||||
"""
|
||||
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
|
||||
|
||||
def set_fft(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the FFT of the image.
|
||||
If name is not specified, then set FFT for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform FFT on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_fft", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_log(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the log of the image.
|
||||
If name is not specified, then set log for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform log on the monitor data.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_log", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def set_rotation(self, deg_90: int = 0, name: str = None):
|
||||
"""
|
||||
Set the rotation of the image.
|
||||
If name is not specified, then set rotation for all images.
|
||||
|
||||
Args:
|
||||
deg_90(int): The rotation angle of the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_rotation", args=[deg_90], kwargs={}, image_id=name)
|
||||
|
||||
def set_transpose(self, enable: bool = False, name: str = None):
|
||||
"""
|
||||
Set the transpose of the image.
|
||||
If name is not specified, then set transpose for all images.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to transpose the monitor data before displaying.
|
||||
name(str): The name of the image. If None, apply to all images.
|
||||
"""
|
||||
self.apply_setting_to_images("set_transpose", args=[enable], kwargs={}, image_id=name)
|
||||
|
||||
def toggle_threading(self, use_threading: bool):
|
||||
"""
|
||||
Toggle threading for the widgets postprocessing and updating.
|
||||
|
||||
Args:
|
||||
use_threading(bool): Whether to use threading.
|
||||
"""
|
||||
self.use_threading = use_threading
|
||||
if self.use_threading is False and self.thread.isRunning():
|
||||
self.cleanup()
|
||||
|
||||
def process_image(self, device: str, image: BECImageItem, data: np.ndarray):
|
||||
"""
|
||||
Process the image data.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device - image_id of image.
|
||||
image(np.ndarray): The image data to be processed.
|
||||
data(np.ndarray): The image data to be processed.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The processed image data.
|
||||
"""
|
||||
processing_config = image.config.processing
|
||||
self.processor.set_config(processing_config)
|
||||
if self.use_threading:
|
||||
self._create_thread_worker(device, data)
|
||||
else:
|
||||
data = self.processor.process_image(data)
|
||||
self.update_image(device, data)
|
||||
self.update_vrange(device, self.processor.config.stats)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_image_update(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Update the image of the device monitor from bec.
|
||||
|
||||
Args:
|
||||
msg(dict): The message from bec.
|
||||
metadata(dict): The metadata of the message.
|
||||
"""
|
||||
data = msg["data"]
|
||||
device = msg["device"]
|
||||
if self.image_type == "device_monitor_1d":
|
||||
image = self._images["device_monitor_1d"][device]
|
||||
current_scan_id = metadata.get("scan_id", None)
|
||||
if current_scan_id is None:
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self.reset()
|
||||
self.scan_id = current_scan_id
|
||||
image.image_buffer_list = []
|
||||
image.max_len = 0
|
||||
image_buffer = self.adjust_image_buffer(image, data)
|
||||
image.raw_data = image_buffer
|
||||
self.process_image(device, image, image_buffer)
|
||||
elif self.image_type == "device_monitor_2d":
|
||||
image = self._images["device_monitor_2d"][device]
|
||||
image.raw_data = data
|
||||
self.process_image(device, image, data)
|
||||
|
||||
def adjust_image_buffer(self, image: BECImageItem, new_data: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
|
||||
|
||||
Args:
|
||||
image: The image object (used to store buffer list and max_len).
|
||||
new_data (np.ndarray): The new incoming 1D waveform data.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The updated image buffer with adjusted shapes.
|
||||
"""
|
||||
new_len = new_data.shape[0]
|
||||
if not hasattr(image, "image_buffer_list"):
|
||||
image.image_buffer_list = []
|
||||
image.max_len = 0
|
||||
|
||||
if new_len > image.max_len:
|
||||
image.max_len = new_len
|
||||
for i in range(len(image.image_buffer_list)):
|
||||
wf = image.image_buffer_list[i]
|
||||
pad_width = image.max_len - wf.shape[0]
|
||||
if pad_width > 0:
|
||||
image.image_buffer_list[i] = np.pad(
|
||||
wf, (0, pad_width), mode="constant", constant_values=0
|
||||
)
|
||||
image.image_buffer_list.append(new_data)
|
||||
else:
|
||||
pad_width = image.max_len - new_len
|
||||
if pad_width > 0:
|
||||
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
|
||||
image.image_buffer_list.append(new_data)
|
||||
|
||||
image_buffer = np.array(image.image_buffer_list)
|
||||
return image_buffer
|
||||
|
||||
@Slot(str, np.ndarray)
|
||||
def update_image(self, device: str, data: np.ndarray):
|
||||
"""
|
||||
Update the image of the device monitor.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device.
|
||||
data(np.ndarray): The data to be updated.
|
||||
"""
|
||||
image_to_update = self._images[self.image_type][device]
|
||||
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
|
||||
|
||||
@Slot(str, ImageStats)
|
||||
def update_vrange(self, device: str, stats: ImageStats):
|
||||
"""
|
||||
Update the scaling of the image.
|
||||
|
||||
Args:
|
||||
stats(ImageStats): The statistics of the image.
|
||||
"""
|
||||
image_to_update = self._images[self.image_type][device]
|
||||
if image_to_update.config.autorange:
|
||||
image_to_update.auto_update_vrange(stats)
|
||||
|
||||
def refresh_image(self):
|
||||
"""
|
||||
Refresh the image.
|
||||
"""
|
||||
for source, images in self._images.items():
|
||||
for image_id, image in images.items():
|
||||
data = image.raw_data
|
||||
self.process_image(image_id, image, data)
|
||||
|
||||
def _connect_device_monitor(self, monitor: str):
|
||||
"""
|
||||
Connect to the device monitor.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
"""
|
||||
image_item = self.find_image_by_monitor(monitor)
|
||||
try:
|
||||
previous_monitor = image_item.config.monitor
|
||||
except AttributeError:
|
||||
previous_monitor = None
|
||||
if previous_monitor and image_item.connected is True:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(previous_monitor)
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_2d(previous_monitor)
|
||||
)
|
||||
image_item.connected = False
|
||||
if monitor and image_item.connected is False:
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
if self.image_type == "device_monitor_1d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
elif self.image_type == "device_monitor_2d":
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
image_item.set_monitor(monitor)
|
||||
image_item.connected = True
|
||||
|
||||
def _add_image_object(
|
||||
self, source: str, name: str, config: ImageItemConfig, data=None
|
||||
) -> BECImageItem:
|
||||
config.parent_id = self.gui_id
|
||||
if self.single_image is True and len(self.images) > 0:
|
||||
self.remove_image(0)
|
||||
image = BECImageItem(config=config, parent_image=self)
|
||||
self.plot_item.addItem(image)
|
||||
self._images[source][name] = image
|
||||
self._connect_device_monitor(config.monitor)
|
||||
self.config.images[name] = config
|
||||
if data is not None:
|
||||
image.setImage(data)
|
||||
return image
|
||||
|
||||
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
|
||||
"""
|
||||
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
|
||||
|
||||
Args:
|
||||
val(Any): Value to check.
|
||||
dict_to_check(dict): Dictionary to check.
|
||||
|
||||
Returns:
|
||||
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
|
||||
"""
|
||||
if val in dict_to_check.keys():
|
||||
return True
|
||||
for key in dict_to_check:
|
||||
if isinstance(dict_to_check[key], dict):
|
||||
if self._check_image_id(val, dict_to_check[key]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_image(self, *identifiers):
|
||||
"""
|
||||
Remove an image from the plot widget.
|
||||
|
||||
Args:
|
||||
*identifiers: Identifier of the image to be removed. Can be either an integer (index) or a string (image_id).
|
||||
"""
|
||||
for identifier in identifiers:
|
||||
if isinstance(identifier, int):
|
||||
self._remove_image_by_order(identifier)
|
||||
elif isinstance(identifier, str):
|
||||
self._remove_image_by_id(identifier)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Each identifier must be either an integer (index) or a string (image_id)."
|
||||
)
|
||||
|
||||
def _remove_image_by_id(self, image_id):
|
||||
for source, images in self._images.items():
|
||||
if image_id in images:
|
||||
self._disconnect_monitor(image_id)
|
||||
image = images.pop(image_id)
|
||||
self.removeItem(image.color_bar)
|
||||
self.plot_item.removeItem(image)
|
||||
del self.config.images[image_id]
|
||||
if image in self.images:
|
||||
self.images.remove(image)
|
||||
return
|
||||
raise KeyError(f"Image with ID '{image_id}' not found.")
|
||||
|
||||
def _remove_image_by_order(self, N):
|
||||
"""
|
||||
Remove an image by its order from the plot widget.
|
||||
|
||||
Args:
|
||||
N(int): Order of the image to be removed.
|
||||
"""
|
||||
if N < len(self.images):
|
||||
image = self.images[N]
|
||||
image_id = image.config.monitor
|
||||
self._disconnect_monitor(image_id)
|
||||
self.removeItem(image.color_bar)
|
||||
self.plot_item.removeItem(image)
|
||||
del self.config.images[image_id]
|
||||
for source, images in self._images.items():
|
||||
if image_id in images:
|
||||
del images[image_id]
|
||||
break
|
||||
else:
|
||||
raise IndexError(f"Image order {N} out of range.")
|
||||
|
||||
def _disconnect_monitor(self, image_id):
|
||||
"""
|
||||
Disconnect the monitor from the device.
|
||||
|
||||
Args:
|
||||
image_id(str): The ID of the monitor.
|
||||
"""
|
||||
image = self.find_image_by_monitor(image_id)
|
||||
if image:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(image.config.monitor)
|
||||
)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_2d(image.config.monitor)
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the widget.
|
||||
"""
|
||||
for monitor in self._images[self.image_type]:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
|
||||
)
|
||||
self.images.clear()
|
||||
|
||||
def cleanup_pyqtgraph(self):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
super().cleanup_pyqtgraph()
|
||||
item = self.plot_item
|
||||
if not item.items:
|
||||
return
|
||||
cbar = item.items[0].color_bar
|
||||
cbar.vb.menu.close()
|
||||
cbar.vb.menu.deleteLater()
|
||||
cbar.gradient.menu.close()
|
||||
cbar.gradient.menu.deleteLater()
|
||||
cbar.gradient.colorDialog.close()
|
||||
cbar.gradient.colorDialog.deleteLater()
|
||||
337
bec_widgets/widgets/containers/figure/plots/image/image_item.py
Normal file
337
bec_widgets/widgets/containers/figure/plots/image/image_item.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
|
||||
ImageStats,
|
||||
ProcessingConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ImageItemConfig(ConnectionConfig):
|
||||
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
|
||||
monitor: Optional[str] = Field(None, description="The name of the monitor.")
|
||||
source: Optional[str] = Field(None, description="The source of the curve.")
|
||||
color_map: Optional[str] = Field("magma", description="The color map of the image.")
|
||||
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
|
||||
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
|
||||
vrange: Optional[tuple[float | int, float | int]] = Field(
|
||||
None, description="The range of the color bar. If None, the range is automatically set."
|
||||
)
|
||||
color_bar: Optional[Literal["simple", "full"]] = Field(
|
||||
"simple", description="The type of the color bar."
|
||||
)
|
||||
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
|
||||
autorange_mode: Optional[Literal["max", "mean"]] = Field(
|
||||
"mean", description="Whether to use the mean of the image for autoscaling."
|
||||
)
|
||||
processing: ProcessingConfig = Field(
|
||||
default_factory=ProcessingConfig, description="The post processing of the image."
|
||||
)
|
||||
|
||||
|
||||
class BECImageItem(BECConnector, pg.ImageItem):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_fft",
|
||||
"set_log",
|
||||
"set_rotation",
|
||||
"set_transpose",
|
||||
"set_opacity",
|
||||
"set_autorange",
|
||||
"set_autorange_mode",
|
||||
"set_color_map",
|
||||
"set_auto_downsample",
|
||||
"set_monitor",
|
||||
"set_vrange",
|
||||
"get_data",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[ImageItemConfig] = None,
|
||||
gui_id: Optional[str] = None,
|
||||
parent_image: Optional[BECImageShow] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = ImageItemConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
else:
|
||||
self.config = config
|
||||
super().__init__(config=config, gui_id=gui_id, **kwargs)
|
||||
pg.ImageItem.__init__(self)
|
||||
|
||||
self.parent_image = parent_image
|
||||
self.colorbar_bar = None
|
||||
self._raw_data = None
|
||||
|
||||
self._add_color_bar(
|
||||
self.config.color_bar, self.config.vrange
|
||||
) # TODO can also support None to not have any colorbar
|
||||
self.apply_config()
|
||||
if kwargs:
|
||||
self.set(**kwargs)
|
||||
self.connected = False
|
||||
|
||||
@property
|
||||
def raw_data(self) -> np.ndarray:
|
||||
return self._raw_data
|
||||
|
||||
@raw_data.setter
|
||||
def raw_data(self, data: np.ndarray):
|
||||
self._raw_data = data
|
||||
|
||||
def apply_config(self):
|
||||
"""
|
||||
Apply current configuration.
|
||||
"""
|
||||
self.set_color_map(self.config.color_map)
|
||||
self.set_auto_downsample(self.config.downsample)
|
||||
if self.config.vrange is not None:
|
||||
self.set_vrange(vrange=self.config.vrange)
|
||||
|
||||
def set(self, **kwargs):
|
||||
"""
|
||||
Set the properties of the image.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- downsample
|
||||
- color_map
|
||||
- monitor
|
||||
- opacity
|
||||
- vrange
|
||||
- fft
|
||||
- log
|
||||
- rot
|
||||
- transpose
|
||||
- autorange_mode
|
||||
"""
|
||||
method_map = {
|
||||
"downsample": self.set_auto_downsample,
|
||||
"color_map": self.set_color_map,
|
||||
"monitor": self.set_monitor,
|
||||
"opacity": self.set_opacity,
|
||||
"vrange": self.set_vrange,
|
||||
"fft": self.set_fft,
|
||||
"log": self.set_log,
|
||||
"rot": self.set_rotation,
|
||||
"transpose": self.set_transpose,
|
||||
"autorange_mode": self.set_autorange_mode,
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
if key in method_map:
|
||||
method_map[key](value)
|
||||
else:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def set_fft(self, enable: bool = False):
|
||||
"""
|
||||
Set the FFT of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform FFT on the monitor data.
|
||||
"""
|
||||
self.config.processing.fft = enable
|
||||
|
||||
def set_log(self, enable: bool = False):
|
||||
"""
|
||||
Set the log of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to perform log on the monitor data.
|
||||
"""
|
||||
self.config.processing.log = enable
|
||||
if enable and self.color_bar and self.config.color_bar == "full":
|
||||
self.color_bar.autoHistogramRange()
|
||||
|
||||
def set_rotation(self, deg_90: int = 0):
|
||||
"""
|
||||
Set the rotation of the image.
|
||||
|
||||
Args:
|
||||
deg_90(int): The rotation angle of the monitor data before displaying.
|
||||
"""
|
||||
self.config.processing.rotation = deg_90
|
||||
|
||||
def set_transpose(self, enable: bool = False):
|
||||
"""
|
||||
Set the transpose of the image.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to transpose the image.
|
||||
"""
|
||||
self.config.processing.transpose = enable
|
||||
|
||||
def set_opacity(self, opacity: float = 1.0):
|
||||
"""
|
||||
Set the opacity of the image.
|
||||
|
||||
Args:
|
||||
opacity(float): The opacity of the image.
|
||||
"""
|
||||
self.setOpacity(opacity)
|
||||
self.config.opacity = opacity
|
||||
|
||||
def set_autorange(self, autorange: bool = False):
|
||||
"""
|
||||
Set the autorange of the color bar.
|
||||
|
||||
Args:
|
||||
autorange(bool): Whether to autorange the color bar.
|
||||
"""
|
||||
self.config.autorange = autorange
|
||||
if self.color_bar and autorange:
|
||||
self.color_bar.autoHistogramRange()
|
||||
|
||||
def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"):
|
||||
"""
|
||||
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
|
||||
|
||||
Args:
|
||||
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
|
||||
"""
|
||||
self.config.autorange_mode = mode
|
||||
|
||||
def set_color_map(self, cmap: str = "magma"):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
|
||||
Args:
|
||||
cmap(str): The color map of the image.
|
||||
"""
|
||||
self.setColorMap(cmap)
|
||||
if self.color_bar is not None:
|
||||
if self.config.color_bar == "simple":
|
||||
self.color_bar.setColorMap(cmap)
|
||||
elif self.config.color_bar == "full":
|
||||
self.color_bar.gradient.loadPreset(cmap)
|
||||
self.config.color_map = cmap
|
||||
|
||||
def set_auto_downsample(self, auto: bool = True):
|
||||
"""
|
||||
Set the auto downsample of the image.
|
||||
|
||||
Args:
|
||||
auto(bool): Whether to downsample the image.
|
||||
"""
|
||||
self.setAutoDownsample(auto)
|
||||
self.config.downsample = auto
|
||||
|
||||
def set_monitor(self, monitor: str):
|
||||
"""
|
||||
Set the monitor of the image.
|
||||
|
||||
Args:
|
||||
monitor(str): The name of the monitor.
|
||||
"""
|
||||
self.config.monitor = monitor
|
||||
|
||||
def auto_update_vrange(self, stats: ImageStats) -> None:
|
||||
"""Auto update of the vrange base on the stats of the image.
|
||||
|
||||
Args:
|
||||
stats(ImageStats): The stats of the image.
|
||||
"""
|
||||
fumble_factor = 2
|
||||
if self.config.autorange_mode == "mean":
|
||||
vmin = max(stats.mean - fumble_factor * stats.std, 0)
|
||||
vmax = stats.mean + fumble_factor * stats.std
|
||||
self.set_vrange(vmin, vmax, change_autorange=False)
|
||||
return
|
||||
if self.config.autorange_mode == "max":
|
||||
self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False)
|
||||
return
|
||||
|
||||
def set_vrange(
|
||||
self,
|
||||
vmin: float = None,
|
||||
vmax: float = None,
|
||||
vrange: tuple[float, float] = None,
|
||||
change_autorange: bool = True,
|
||||
):
|
||||
"""
|
||||
Set the range of the color bar.
|
||||
|
||||
Args:
|
||||
vmin(float): Minimum value of the color bar.
|
||||
vmax(float): Maximum value of the color bar.
|
||||
"""
|
||||
if vrange is not None:
|
||||
vmin, vmax = vrange
|
||||
self.setLevels([vmin, vmax])
|
||||
self.config.vrange = (vmin, vmax)
|
||||
if change_autorange:
|
||||
self.config.autorange = False
|
||||
if self.color_bar is not None:
|
||||
if self.config.color_bar == "simple":
|
||||
self.color_bar.setLevels(low=vmin, high=vmax)
|
||||
elif self.config.color_bar == "full":
|
||||
# pylint: disable=unexpected-keyword-arg
|
||||
self.color_bar.setLevels(min=vmin, max=vmax)
|
||||
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
|
||||
|
||||
def get_data(self) -> np.ndarray:
|
||||
"""
|
||||
Get the data of the image.
|
||||
Returns:
|
||||
np.ndarray: The data of the image.
|
||||
"""
|
||||
return self.image
|
||||
|
||||
def _add_color_bar(
|
||||
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
|
||||
):
|
||||
"""
|
||||
Add color bar to the layout.
|
||||
|
||||
Args:
|
||||
style(Literal["simple,full"]): The style of the color bar.
|
||||
vrange(tuple[int,int]): The range of the color bar.
|
||||
"""
|
||||
if color_bar_style == "simple":
|
||||
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
if vrange is not None:
|
||||
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
|
||||
self.color_bar.setImageItem(self)
|
||||
self.parent_image.addItem(self.color_bar, row=1, col=1)
|
||||
self.config.color_bar = "simple"
|
||||
elif color_bar_style == "full":
|
||||
# Setting histogram
|
||||
self.color_bar = pg.HistogramLUTItem()
|
||||
self.color_bar.setImageItem(self)
|
||||
self.color_bar.gradient.loadPreset(self.config.color_map)
|
||||
if vrange is not None:
|
||||
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
|
||||
self.color_bar.setHistogramRange(
|
||||
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
|
||||
)
|
||||
|
||||
# Adding histogram to the layout
|
||||
self.parent_image.addItem(self.color_bar, row=1, col=1)
|
||||
|
||||
# save settings
|
||||
self.config.color_bar = "full"
|
||||
else:
|
||||
raise ValueError("style should be 'simple' or 'full'")
|
||||
|
||||
def remove(self):
|
||||
"""Remove the curve from the plot."""
|
||||
self.parent_image.remove_image(self.config.monitor)
|
||||
self.rpc_register.remove_rpc(self)
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -16,51 +17,35 @@ class ImageStats:
|
||||
mean: float
|
||||
std: float
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: np.ndarray) -> ImageStats:
|
||||
"""
|
||||
Get the statistics of the image data.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The image data.
|
||||
|
||||
Returns:
|
||||
ImageStats: The statistics of the image data.
|
||||
"""
|
||||
return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data))
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
class ProcessingConfig(BaseModel):
|
||||
fft: bool = Field(False, description="Whether to perform FFT on the monitor data.")
|
||||
log: bool = Field(False, description="Whether to perform log on the monitor data.")
|
||||
transpose: bool = Field(
|
||||
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
|
||||
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
|
||||
center_of_mass: Optional[bool] = Field(
|
||||
False, description="Whether to calculate the center of mass of the monitor data."
|
||||
)
|
||||
transpose: Optional[bool] = Field(
|
||||
False, description="Whether to transpose the monitor data before displaying."
|
||||
)
|
||||
num_rotation_90: int = Field(
|
||||
0, description="The rotation angle of the monitor data before displaying."
|
||||
rotation: Optional[int] = Field(
|
||||
None, description="The rotation angle of the monitor data before displaying."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
stats: ImageStats = Field(
|
||||
ImageStats(maximum=0, minimum=0, mean=0, std=0),
|
||||
description="The statistics of the image data.",
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class ImageProcessor(QObject):
|
||||
class ImageProcessor:
|
||||
"""
|
||||
Class for processing the image data.
|
||||
"""
|
||||
|
||||
image_processed = Signal(np.ndarray)
|
||||
|
||||
def __init__(self, parent=None, config: ProcessingConfig = None):
|
||||
super().__init__(parent=parent)
|
||||
def __init__(self, config: ProcessingConfig = None):
|
||||
if config is None:
|
||||
config = ProcessingConfig()
|
||||
self.config = config
|
||||
self._current_thread = None
|
||||
|
||||
def set_config(self, config: ProcessingConfig):
|
||||
"""
|
||||
@@ -124,6 +109,9 @@ class ImageProcessor(QObject):
|
||||
data_offset = data + offset
|
||||
return np.log10(data_offset)
|
||||
|
||||
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
|
||||
# return np.unravel_index(np.argmax(data), data.shape)
|
||||
|
||||
def update_image_stats(self, data: np.ndarray) -> None:
|
||||
"""Get the statistics of the image data.
|
||||
|
||||
@@ -137,14 +125,59 @@ class ImageProcessor(QObject):
|
||||
self.config.stats.std = np.std(data)
|
||||
|
||||
def process_image(self, data: np.ndarray) -> np.ndarray:
|
||||
"""Core processing logic without threading overhead."""
|
||||
"""
|
||||
Process the data according to the configuration.
|
||||
|
||||
Args:
|
||||
data(np.ndarray): The data to be processed.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The processed data.
|
||||
"""
|
||||
if self.config.fft:
|
||||
data = self.FFT(data)
|
||||
if self.config.num_rotation_90 is not None:
|
||||
data = self.rotation(data, self.config.num_rotation_90)
|
||||
if self.config.rotation is not None:
|
||||
data = self.rotation(data, self.config.rotation)
|
||||
if self.config.transpose:
|
||||
data = self.transpose(data)
|
||||
if self.config.log:
|
||||
data = self.log(data)
|
||||
self.update_image_stats(data)
|
||||
return data
|
||||
|
||||
|
||||
class ProcessorWorker(QObject):
|
||||
"""
|
||||
Worker for processing the image data.
|
||||
"""
|
||||
|
||||
processed = Signal(str, np.ndarray)
|
||||
stats = Signal(str, ImageStats)
|
||||
stopRequested = Signal()
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, processor):
|
||||
super().__init__()
|
||||
self.processor = processor
|
||||
self._isRunning = False
|
||||
self.stopRequested.connect(self.stop)
|
||||
|
||||
@Slot(str, np.ndarray)
|
||||
def process_image(self, device: str, image: np.ndarray):
|
||||
"""
|
||||
Process the image data.
|
||||
|
||||
Args:
|
||||
device(str): The name of the device.
|
||||
image(np.ndarray): The image data.
|
||||
"""
|
||||
self._isRunning = True
|
||||
processed_image = self.processor.process_image(image)
|
||||
self._isRunning = False
|
||||
if not self._isRunning:
|
||||
self.processed.emit(device, processed_image)
|
||||
self.stats.emit(self.processor.config.stats)
|
||||
self.finished.emit()
|
||||
|
||||
def stop(self):
|
||||
self._isRunning = False
|
||||
@@ -0,0 +1,525 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.utils import Colors, EntryValidator
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Signal, SignalData
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MotorMapConfig(SubplotConfig):
|
||||
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
|
||||
color: Optional[str | tuple] = Field(
|
||||
(255, 255, 255, 255), description="The color of the last point of current position."
|
||||
)
|
||||
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
|
||||
max_points: Optional[int] = Field(5000, description="Maximum number of points to display.")
|
||||
num_dim_points: Optional[int] = Field(
|
||||
100,
|
||||
description="Number of points to dim before the color remains same for older recorded position.",
|
||||
)
|
||||
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
|
||||
background_value: Optional[int] = Field(
|
||||
25, description="Background value of the motor map. Has to be between 0 and 255."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
_validate_color = field_validator("color")(Colors.validate_color)
|
||||
|
||||
@field_validator("background_value")
|
||||
def validate_background_value(cls, value):
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class BECMotorMap(BECPlotBase):
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"change_motors",
|
||||
"set_max_points",
|
||||
"set_precision",
|
||||
"set_num_dim_points",
|
||||
"set_background_value",
|
||||
"set_scatter_size",
|
||||
"get_data",
|
||||
"export",
|
||||
"remove",
|
||||
"reset_history",
|
||||
]
|
||||
|
||||
# QT Signals
|
||||
update_signal = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[MotorMapConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = MotorMapConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
self.entry_validator = EntryValidator(self.dev)
|
||||
|
||||
# connect update signal to update plot
|
||||
self.proxy_update_plot = pg.SignalProxy(
|
||||
self.update_signal, rateLimit=25, slot=self._update_plot
|
||||
)
|
||||
self.apply_config(self.config)
|
||||
|
||||
def apply_config(self, config: dict | MotorMapConfig):
|
||||
"""
|
||||
Apply the config to the motor map.
|
||||
|
||||
Args:
|
||||
config(dict|MotorMapConfig): Config to be applied.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
try:
|
||||
config = MotorMapConfig(**config)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Error in applying config: {e}")
|
||||
return
|
||||
|
||||
self.config = config
|
||||
self.plot_item.clear()
|
||||
|
||||
self.motor_x = None
|
||||
self.motor_y = None
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
self.plot_components = defaultdict(dict) # container for plot components
|
||||
|
||||
self.apply_axis_config()
|
||||
|
||||
if self.config.signals is not None:
|
||||
self.change_motors(
|
||||
motor_x=self.config.signals.x.name,
|
||||
motor_y=self.config.signals.y.name,
|
||||
motor_x_entry=self.config.signals.x.entry,
|
||||
motor_y_entry=self.config.signals.y.entry,
|
||||
)
|
||||
|
||||
@Slot(str, str, str, str, bool)
|
||||
def change_motors(
|
||||
self,
|
||||
motor_x: str,
|
||||
motor_y: str,
|
||||
motor_x_entry: str = None,
|
||||
motor_y_entry: str = None,
|
||||
validate_bec: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Change the active motors for the plot.
|
||||
|
||||
Args:
|
||||
motor_x(str): Motor name for the X axis.
|
||||
motor_y(str): Motor name for the Y axis.
|
||||
motor_x_entry(str): Motor entry for the X axis.
|
||||
motor_y_entry(str): Motor entry for the Y axis.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
"""
|
||||
self.plot_item.clear()
|
||||
|
||||
motor_x_entry, motor_y_entry = self._validate_signal_entries(
|
||||
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
|
||||
)
|
||||
|
||||
motor_x_limit = self._get_motor_limit(motor_x)
|
||||
motor_y_limit = self._get_motor_limit(motor_y)
|
||||
|
||||
signal = Signal(
|
||||
source="device_readback",
|
||||
x=SignalData(name=motor_x, entry=motor_x_entry, limits=motor_x_limit),
|
||||
y=SignalData(name=motor_y, entry=motor_y_entry, limits=motor_y_limit),
|
||||
)
|
||||
self.config.signals = signal
|
||||
|
||||
# reconnect the signals
|
||||
self._connect_motor_to_slots()
|
||||
|
||||
self.database_buffer = {"x": [], "y": []}
|
||||
|
||||
# Redraw the motor map
|
||||
self._make_motor_map()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
"""
|
||||
Get the data of the motor map.
|
||||
|
||||
Returns:
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
|
||||
return data
|
||||
|
||||
def reset_history(self):
|
||||
"""
|
||||
Reset the history of the motor map.
|
||||
"""
|
||||
self.database_buffer["x"] = [self.database_buffer["x"][-1]]
|
||||
self.database_buffer["y"] = [self.database_buffer["y"][-1]]
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_color(self, color: str | tuple):
|
||||
"""
|
||||
Set color of the motor trace.
|
||||
|
||||
Args:
|
||||
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
|
||||
"""
|
||||
if isinstance(color, str):
|
||||
color = Colors.validate_color(color)
|
||||
color = Colors.hex_to_rgba(color, 255)
|
||||
self.config.color = color
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_max_points(self, max_points: int) -> None:
|
||||
"""
|
||||
Set the maximum number of points to display.
|
||||
|
||||
Args:
|
||||
max_points(int): Maximum number of points to display.
|
||||
"""
|
||||
self.config.max_points = max_points
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_precision(self, precision: int) -> None:
|
||||
"""
|
||||
Set the decimal precision of the motor position.
|
||||
|
||||
Args:
|
||||
precision(int): Decimal precision of the motor position.
|
||||
"""
|
||||
self.config.precision = precision
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_num_dim_points(self, num_dim_points: int) -> None:
|
||||
"""
|
||||
Set the number of dim points for the motor map.
|
||||
|
||||
Args:
|
||||
num_dim_points(int): Number of dim points.
|
||||
"""
|
||||
self.config.num_dim_points = num_dim_points
|
||||
self.update_signal.emit()
|
||||
|
||||
def set_background_value(self, background_value: int) -> None:
|
||||
"""
|
||||
Set the background value of the motor map.
|
||||
|
||||
Args:
|
||||
background_value(int): Background value of the motor map.
|
||||
"""
|
||||
self.config.background_value = background_value
|
||||
self._swap_limit_map()
|
||||
|
||||
def set_scatter_size(self, scatter_size: int) -> None:
|
||||
"""
|
||||
Set the scatter size of the motor map plot.
|
||||
|
||||
Args:
|
||||
scatter_size(int): Size of the scatter points.
|
||||
"""
|
||||
self.config.scatter_size = scatter_size
|
||||
self.update_signal.emit()
|
||||
|
||||
def _disconnect_current_motors(self):
|
||||
"""Disconnect the current motors from the slots."""
|
||||
if self.motor_x is not None and self.motor_y is not None:
|
||||
endpoints = [
|
||||
MessageEndpoints.device_readback(self.motor_x),
|
||||
MessageEndpoints.device_readback(self.motor_y),
|
||||
]
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _connect_motor_to_slots(self):
|
||||
"""Connect motors to slots."""
|
||||
self._disconnect_current_motors()
|
||||
|
||||
self.motor_x = self.config.signals.x.name
|
||||
self.motor_y = self.config.signals.y.name
|
||||
|
||||
endpoints = [
|
||||
MessageEndpoints.device_readback(self.motor_x),
|
||||
MessageEndpoints.device_readback(self.motor_y),
|
||||
]
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
|
||||
|
||||
def _swap_limit_map(self):
|
||||
"""Swap the limit map."""
|
||||
self.plot_item.removeItem(self.plot_components["limit_map"])
|
||||
if self.config.signals.x.limits is not None and self.config.signals.y.limits is not None:
|
||||
self.plot_components["limit_map"] = self._make_limit_map(
|
||||
self.config.signals.x.limits, self.config.signals.y.limits
|
||||
)
|
||||
self.plot_components["limit_map"].setZValue(-1)
|
||||
self.plot_item.addItem(self.plot_components["limit_map"])
|
||||
|
||||
def _make_motor_map(self):
|
||||
"""
|
||||
Create the motor map plot.
|
||||
"""
|
||||
# Create limit map
|
||||
motor_x_limit = self.config.signals.x.limits
|
||||
motor_y_limit = self.config.signals.y.limits
|
||||
if motor_x_limit is not None or motor_y_limit is not None:
|
||||
self.plot_components["limit_map"] = self._make_limit_map(motor_x_limit, motor_y_limit)
|
||||
self.plot_item.addItem(self.plot_components["limit_map"])
|
||||
self.plot_components["limit_map"].setZValue(-1)
|
||||
|
||||
# Create scatter plot
|
||||
scatter_size = self.config.scatter_size
|
||||
self.plot_components["scatter"] = pg.ScatterPlotItem(
|
||||
size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
self.plot_item.addItem(self.plot_components["scatter"])
|
||||
self.plot_components["scatter"].setZValue(0)
|
||||
|
||||
# Enable Grid
|
||||
self.set_grid(True, True)
|
||||
|
||||
# Add the crosshair for initial motor coordinates
|
||||
initial_position_x = self._get_motor_init_position(
|
||||
self.motor_x, self.config.signals.x.entry, self.config.precision
|
||||
)
|
||||
initial_position_y = self._get_motor_init_position(
|
||||
self.motor_y, self.config.signals.y.entry, self.config.precision
|
||||
)
|
||||
|
||||
self.database_buffer["x"] = [initial_position_x]
|
||||
self.database_buffer["y"] = [initial_position_y]
|
||||
|
||||
self.plot_components["scatter"].setData([initial_position_x], [initial_position_y])
|
||||
self._add_coordinantes_crosshair(initial_position_x, initial_position_y)
|
||||
|
||||
# Set default labels for the plot
|
||||
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
|
||||
"""
|
||||
Add crosshair to the plot to highlight the current position.
|
||||
|
||||
Args:
|
||||
x(float): X coordinate.
|
||||
y(float): Y coordinate.
|
||||
"""
|
||||
|
||||
# Crosshair to highlight the current position
|
||||
highlight_H = pg.InfiniteLine(
|
||||
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
highlight_V = pg.InfiniteLine(
|
||||
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
|
||||
)
|
||||
|
||||
# Add crosshair to the curve list for future referencing
|
||||
self.plot_components["highlight_H"] = highlight_H
|
||||
self.plot_components["highlight_V"] = highlight_V
|
||||
|
||||
# Add crosshair to the plot
|
||||
self.plot_item.addItem(highlight_H)
|
||||
self.plot_item.addItem(highlight_V)
|
||||
|
||||
highlight_V.setPos(x)
|
||||
highlight_H.setPos(y)
|
||||
|
||||
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
|
||||
"""
|
||||
Create a limit map for the motor map plot.
|
||||
|
||||
Args:
|
||||
limits_x(list): Motor limits for the x axis.
|
||||
limits_y(list): Motor limits for the y axis.
|
||||
|
||||
Returns:
|
||||
pg.ImageItem: Limit map.
|
||||
"""
|
||||
limit_x_min, limit_x_max = limits_x
|
||||
limit_y_min, limit_y_max = limits_y
|
||||
|
||||
map_width = int(limit_x_max - limit_x_min + 1)
|
||||
map_height = int(limit_y_max - limit_y_min + 1)
|
||||
|
||||
# Create limits map
|
||||
background_value = self.config.background_value
|
||||
limit_map_data = np.full((map_width, map_height), background_value, dtype=np.float32)
|
||||
limit_map = pg.ImageItem()
|
||||
limit_map.setImage(limit_map_data)
|
||||
|
||||
# Translate and scale the image item to match the motor coordinates
|
||||
tr = QtGui.QTransform()
|
||||
tr.translate(limit_x_min, limit_y_min)
|
||||
limit_map.setTransform(tr)
|
||||
|
||||
return limit_map
|
||||
|
||||
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
|
||||
"""
|
||||
Get the motor initial position from the config.
|
||||
|
||||
Args:
|
||||
name(str): Motor name.
|
||||
entry(str): Motor entry.
|
||||
precision(int): Decimal precision of the motor position.
|
||||
|
||||
Returns:
|
||||
float: Motor initial position.
|
||||
"""
|
||||
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
|
||||
return init_position
|
||||
|
||||
def _validate_signal_entries(
|
||||
self,
|
||||
x_name: str,
|
||||
y_name: str,
|
||||
x_entry: str | None,
|
||||
y_entry: str | None,
|
||||
validate_bec: bool = True,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Validate the signal name and entry.
|
||||
|
||||
Args:
|
||||
x_name(str): Name of the x signal.
|
||||
y_name(str): Name of the y signal.
|
||||
x_entry(str|None): Entry of the x signal.
|
||||
y_entry(str|None): Entry of the y signal.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
|
||||
Returns:
|
||||
tuple[str,str]: Validated x and y entries.
|
||||
"""
|
||||
if validate_bec:
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
|
||||
else:
|
||||
x_entry = x_name if x_entry is None else x_entry
|
||||
y_entry = y_name if y_entry is None else y_entry
|
||||
return x_entry, y_entry
|
||||
|
||||
def _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
|
||||
"""
|
||||
Get the motor limit from the config.
|
||||
|
||||
Args:
|
||||
motor(str): Motor name.
|
||||
|
||||
Returns:
|
||||
float: Motor limit.
|
||||
"""
|
||||
try:
|
||||
limits = self.dev[motor].limits
|
||||
if limits == [0, 0]:
|
||||
return None
|
||||
return limits
|
||||
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
|
||||
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
|
||||
logger.error(f"The device '{motor}' does not have defined limits.")
|
||||
return None
|
||||
|
||||
@Slot()
|
||||
def _update_plot(self, _=None):
|
||||
"""Update the motor map plot."""
|
||||
# If the number of points exceeds max_points, delete the oldest points
|
||||
if len(self.database_buffer["x"]) > self.config.max_points:
|
||||
self.database_buffer["x"] = self.database_buffer["x"][-self.config.max_points :]
|
||||
self.database_buffer["y"] = self.database_buffer["y"][-self.config.max_points :]
|
||||
|
||||
x = self.database_buffer["x"]
|
||||
y = self.database_buffer["y"]
|
||||
|
||||
# Setup gradient brush for history
|
||||
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
|
||||
|
||||
# RGB color
|
||||
r, g, b, a = self.config.color
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
num_dim_points = self.config.num_dim_points
|
||||
decrement_step = (255 - 50) / num_dim_points
|
||||
|
||||
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
|
||||
brightness = max(60, 255 - decrement_step * (i - 1))
|
||||
dim_r = int(r * (brightness / 255))
|
||||
dim_g = int(g * (brightness / 255))
|
||||
dim_b = int(b * (brightness / 255))
|
||||
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
|
||||
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
|
||||
scatter_size = self.config.scatter_size
|
||||
|
||||
# Update the scatter plot
|
||||
self.plot_components["scatter"].setData(
|
||||
x=x, y=y, brush=brushes, pen=None, size=scatter_size
|
||||
)
|
||||
|
||||
# Get last know position for crosshair
|
||||
current_x = x[-1]
|
||||
current_y = y[-1]
|
||||
|
||||
# Update the crosshair
|
||||
self.plot_components["highlight_V"].setPos(current_x)
|
||||
self.plot_components["highlight_H"].setPos(current_y)
|
||||
|
||||
# TODO not update title but some label
|
||||
# Update plot title
|
||||
precision = self.config.precision
|
||||
self.set_title(
|
||||
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
|
||||
)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update the motor map plot with the new motor position.
|
||||
|
||||
Args:
|
||||
msg(dict): Message from the device readback.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
if self.motor_x is None or self.motor_y is None:
|
||||
return
|
||||
|
||||
if self.motor_x in msg["signals"]:
|
||||
x = msg["signals"][self.motor_x]["value"]
|
||||
self.database_buffer["x"].append(x)
|
||||
self.database_buffer["y"].append(self.database_buffer["y"][-1])
|
||||
|
||||
elif self.motor_y in msg["signals"]:
|
||||
y = msg["signals"][self.motor_y]["value"]
|
||||
self.database_buffer["y"].append(y)
|
||||
self.database_buffer["x"].append(self.database_buffer["x"][-1])
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self._disconnect_current_motors()
|
||||
@@ -0,0 +1,340 @@
|
||||
from collections import deque
|
||||
from typing import Literal, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import Field, field_validator
|
||||
from pyqtgraph.exporters import MatplotlibExporter
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECMultiWaveformConfig(SubplotConfig):
|
||||
color_palette: Optional[str] = Field(
|
||||
"magma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
curve_limit: Optional[int] = Field(
|
||||
200, description="The maximum number of curves to display on the plot."
|
||||
)
|
||||
flush_buffer: Optional[bool] = Field(
|
||||
False, description="Flush the buffer of the plot widget when the curve limit is reached."
|
||||
)
|
||||
monitor: Optional[str] = Field(
|
||||
None, description="The monitor to set for the plot widget."
|
||||
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
|
||||
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
|
||||
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
|
||||
highlight_last_curve: Optional[bool] = Field(
|
||||
True, description="Highlight the last curve on the plot."
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class BECMultiWaveform(BECPlotBase):
|
||||
monitor_signal_updated = Signal()
|
||||
highlighted_curve_index_changed = Signal(int)
|
||||
USER_ACCESS = [
|
||||
"_rpc_id",
|
||||
"_config_dict",
|
||||
"curves",
|
||||
"set_monitor",
|
||||
"set_opacity",
|
||||
"set_curve_limit",
|
||||
"set_curve_highlight",
|
||||
"set_colormap",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"set_colormap",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"get_all_data",
|
||||
"remove",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent_figure=None,
|
||||
config: Optional[BECMultiWaveformConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(
|
||||
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
|
||||
)
|
||||
self.old_scan_id = None
|
||||
self.scan_id = None
|
||||
self.monitor = None
|
||||
self.connected = False
|
||||
self.current_highlight_index = 0
|
||||
self._curves = deque()
|
||||
self.visible_curves = []
|
||||
self.number_of_visible_curves = 0
|
||||
|
||||
# Get bec shortcuts dev, scans, queue, scan_storage, dap
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@property
|
||||
def curves(self) -> deque:
|
||||
"""
|
||||
Get the curves of the plot widget as a deque.
|
||||
Returns:
|
||||
deque: Deque of curves.
|
||||
"""
|
||||
return self._curves
|
||||
|
||||
@curves.setter
|
||||
def curves(self, value: deque):
|
||||
self._curves = value
|
||||
|
||||
@property
|
||||
def highlight_last_curve(self) -> bool:
|
||||
"""
|
||||
Get the highlight_last_curve property.
|
||||
Returns:
|
||||
bool: The highlight_last_curve property.
|
||||
"""
|
||||
return self.config.highlight_last_curve
|
||||
|
||||
@highlight_last_curve.setter
|
||||
def highlight_last_curve(self, value: bool):
|
||||
self.config.highlight_last_curve = value
|
||||
|
||||
def set_monitor(self, monitor: str):
|
||||
"""
|
||||
Set the monitor for the plot widget.
|
||||
Args:
|
||||
monitor (str): The monitor to set.
|
||||
"""
|
||||
self.config.monitor = monitor
|
||||
self._connect_monitor()
|
||||
|
||||
def _connect_monitor(self):
|
||||
"""
|
||||
Connect the monitor to the plot widget.
|
||||
"""
|
||||
try:
|
||||
previous_monitor = self.monitor
|
||||
except AttributeError:
|
||||
previous_monitor = None
|
||||
|
||||
if previous_monitor and self.connected is True:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
|
||||
)
|
||||
if self.config.monitor and self.connected is False:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
|
||||
)
|
||||
self.connected = True
|
||||
self.monitor = self.config.monitor
|
||||
|
||||
@Slot(dict, dict)
|
||||
def on_monitor_1d_update(self, msg: dict, metadata: dict):
|
||||
"""
|
||||
Update the plot widget with the monitor data.
|
||||
|
||||
Args:
|
||||
msg(dict): The message data.
|
||||
metadata(dict): The metadata of the message.
|
||||
"""
|
||||
data = msg.get("data", None)
|
||||
current_scan_id = metadata.get("scan_id", None)
|
||||
|
||||
if current_scan_id != self.scan_id:
|
||||
self.scan_id = current_scan_id
|
||||
self.clear_curves()
|
||||
self.curves.clear()
|
||||
if self.crosshair:
|
||||
self.crosshair.clear_markers()
|
||||
|
||||
# Always create a new curve and add it
|
||||
curve = pg.PlotDataItem()
|
||||
curve.setData(data)
|
||||
self.plot_item.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
|
||||
# Max Trace and scale colors
|
||||
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
|
||||
|
||||
self.monitor_signal_updated.emit()
|
||||
|
||||
@Slot(int)
|
||||
def set_curve_highlight(self, index: int):
|
||||
"""
|
||||
Set the curve highlight based on visible curves.
|
||||
|
||||
Args:
|
||||
index (int): The index of the curve to highlight among visible curves.
|
||||
"""
|
||||
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
|
||||
num_visible_curves = len(self.plot_item.visible_curves)
|
||||
self.number_of_visible_curves = num_visible_curves
|
||||
|
||||
if num_visible_curves == 0:
|
||||
return # No curves to highlight
|
||||
|
||||
if index >= num_visible_curves:
|
||||
index = num_visible_curves - 1
|
||||
elif index < 0:
|
||||
index = num_visible_curves + index
|
||||
self.current_highlight_index = index
|
||||
num_colors = num_visible_curves
|
||||
colors = Colors.evenly_spaced_colors(
|
||||
colormap=self.config.color_palette, num=num_colors, format="HEX"
|
||||
)
|
||||
for i, curve in enumerate(self.plot_item.visible_curves):
|
||||
curve.setPen()
|
||||
if i == self.current_highlight_index:
|
||||
curve.setPen(pg.mkPen(color=colors[i], width=5))
|
||||
curve.setAlpha(alpha=1, auto=False)
|
||||
curve.setZValue(1)
|
||||
else:
|
||||
curve.setPen(pg.mkPen(color=colors[i], width=1))
|
||||
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
|
||||
curve.setZValue(0)
|
||||
|
||||
self.highlighted_curve_index_changed.emit(self.current_highlight_index)
|
||||
|
||||
@Slot(int)
|
||||
def set_opacity(self, opacity: int):
|
||||
"""
|
||||
Set the opacity of the curve on the plot.
|
||||
|
||||
Args:
|
||||
opacity(int): The opacity of the curve. 0-100.
|
||||
"""
|
||||
self.config.opacity = max(0, min(100, opacity))
|
||||
self.set_curve_highlight(self.current_highlight_index)
|
||||
|
||||
@Slot(int, bool)
|
||||
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
|
||||
"""
|
||||
Set the maximum number of traces to display on the plot.
|
||||
|
||||
Args:
|
||||
max_trace (int): The maximum number of traces to display.
|
||||
flush_buffer (bool): Flush the buffer.
|
||||
"""
|
||||
self.config.curve_limit = max_trace
|
||||
self.config.flush_buffer = flush_buffer
|
||||
|
||||
if self.config.curve_limit is None:
|
||||
self.scale_colors()
|
||||
return
|
||||
|
||||
if self.config.flush_buffer:
|
||||
# Remove excess curves from the plot and the deque
|
||||
while len(self.curves) > self.config.curve_limit:
|
||||
curve = self.curves.popleft()
|
||||
self.plot_item.removeItem(curve)
|
||||
else:
|
||||
# Hide or show curves based on the new max_trace
|
||||
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
|
||||
for i, curve in enumerate(self.curves):
|
||||
if i < len(self.curves) - num_curves_to_show:
|
||||
curve.hide()
|
||||
else:
|
||||
curve.show()
|
||||
self.scale_colors()
|
||||
|
||||
def scale_colors(self):
|
||||
"""
|
||||
Scale the colors of the curves based on the current colormap.
|
||||
"""
|
||||
if self.config.highlight_last_curve:
|
||||
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
|
||||
else:
|
||||
self.set_curve_highlight(self.current_highlight_index)
|
||||
|
||||
def set_colormap(self, colormap: str):
|
||||
"""
|
||||
Set the colormap for the curves.
|
||||
|
||||
Args:
|
||||
colormap(str): Colormap for the curves.
|
||||
"""
|
||||
self.config.color_palette = colormap
|
||||
self.set_curve_highlight(self.current_highlight_index)
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
super().hook_crosshair()
|
||||
if self.crosshair:
|
||||
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
|
||||
if self.curves:
|
||||
self.crosshair.update_highlighted_curve(self.current_highlight_index)
|
||||
|
||||
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
|
||||
"""
|
||||
Extract all curve data into a dictionary or a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
output (Literal["dict", "pandas"]): Format of the output data.
|
||||
|
||||
Returns:
|
||||
dict | pd.DataFrame: Data of all curves in the specified format.
|
||||
"""
|
||||
data = {}
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
if output == "pandas":
|
||||
logger.warning(
|
||||
"Pandas is not installed. "
|
||||
"Please install pandas using 'pip install pandas'."
|
||||
"Output will be dictionary instead."
|
||||
)
|
||||
output = "dict"
|
||||
|
||||
curve_keys = []
|
||||
curves_list = list(self.curves)
|
||||
for i, curve in enumerate(curves_list):
|
||||
x_data, y_data = curve.getData()
|
||||
if x_data is not None or y_data is not None:
|
||||
key = f"curve_{i}"
|
||||
curve_keys.append(key)
|
||||
if output == "dict":
|
||||
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
|
||||
elif output == "pandas" and pd is not None:
|
||||
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
|
||||
|
||||
if output == "pandas" and pd is not None:
|
||||
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
|
||||
return combined_data
|
||||
return data
|
||||
|
||||
def clear_curves(self):
|
||||
"""
|
||||
Remove all curves from the plot, excluding crosshair items.
|
||||
"""
|
||||
items_to_remove = []
|
||||
for item in self.plot_item.items:
|
||||
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
|
||||
items_to_remove.append(item)
|
||||
for item in items_to_remove:
|
||||
self.plot_item.removeItem(item)
|
||||
|
||||
def export_to_matplotlib(self):
|
||||
"""
|
||||
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
|
||||
"""
|
||||
MatplotlibExporter(self.plot_item).export()
|
||||
505
bec_widgets/widgets/containers/figure/plots/plot_base.py
Normal file
505
bec_widgets/widgets/containers/figure/plots/plot_base.py
Normal file
@@ -0,0 +1,505 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
import bec_qthemes
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.crosshair import Crosshair
|
||||
from bec_widgets.utils.fps_counter import FPSCounter
|
||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AxisConfig(BaseModel):
|
||||
title: Optional[str] = Field(None, description="The title of the axes.")
|
||||
title_size: Optional[int] = Field(None, description="The font size of the title.")
|
||||
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
|
||||
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
|
||||
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
|
||||
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
|
||||
legend_label_size: Optional[int] = Field(
|
||||
None, description="The font size of the legend labels."
|
||||
)
|
||||
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
|
||||
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
|
||||
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
|
||||
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
|
||||
x_grid: bool = Field(False, description="Show grid on the x-axis.")
|
||||
y_grid: bool = Field(False, description="Show grid on the y-axis.")
|
||||
outer_axes: bool = Field(False, description="Show the outer axes of the plot widget.")
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class SubplotConfig(ConnectionConfig):
|
||||
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
|
||||
|
||||
# Coordinates in the figure
|
||||
row: int = Field(0, description="The row coordinate in the figure.")
|
||||
col: int = Field(0, description="The column coordinate in the figure.")
|
||||
|
||||
# Appearance settings
|
||||
axis: AxisConfig = Field(
|
||||
default_factory=AxisConfig, description="The axis configuration of the plot."
|
||||
)
|
||||
|
||||
|
||||
class BECViewBox(pg.ViewBox):
|
||||
sigPaint = Signal()
|
||||
|
||||
def paint(self, painter, opt, widget):
|
||||
super().paint(painter, opt, widget)
|
||||
self.sigPaint.emit()
|
||||
|
||||
def itemBoundsChanged(self, item):
|
||||
self._itemBoundsCache.pop(item, None)
|
||||
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
|
||||
# check if the call is coming from a mouse-move event
|
||||
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
|
||||
return
|
||||
self._autoRangeNeedsUpdate = True
|
||||
self.update()
|
||||
|
||||
|
||||
class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
||||
crosshair_position_changed = Signal(tuple)
|
||||
crosshair_position_clicked = Signal(tuple)
|
||||
crosshair_coordinates_changed = Signal(tuple)
|
||||
crosshair_coordinates_clicked = Signal(tuple)
|
||||
USER_ACCESS = [
|
||||
"_config_dict",
|
||||
"set",
|
||||
"set_title",
|
||||
"set_x_label",
|
||||
"set_y_label",
|
||||
"set_x_scale",
|
||||
"set_y_scale",
|
||||
"set_x_lim",
|
||||
"set_y_lim",
|
||||
"set_grid",
|
||||
"set_outer_axes",
|
||||
"enable_fps_monitor",
|
||||
"lock_aspect_ratio",
|
||||
"export",
|
||||
"remove",
|
||||
"set_legend_label_size",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None, # TODO decide if needed for this class
|
||||
parent_figure=None,
|
||||
config: Optional[SubplotConfig] = None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
config = SubplotConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
pg.GraphicsLayout.__init__(self, parent)
|
||||
|
||||
self.figure = parent_figure
|
||||
|
||||
self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self)
|
||||
self.addItem(self.plot_item, row=1, col=0)
|
||||
|
||||
self.add_legend()
|
||||
self.crosshair = None
|
||||
self.fps_monitor = None
|
||||
self.fps_label = None
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
|
||||
self._connect_to_theme_change()
|
||||
|
||||
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._update_theme)
|
||||
|
||||
@Slot(str)
|
||||
def _update_theme(self, theme: str):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme"):
|
||||
theme = qapp.theme.theme
|
||||
else:
|
||||
theme = "dark"
|
||||
self.apply_theme(theme)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the plot widget.
|
||||
|
||||
Args:
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
palette = bec_qthemes.load_palette(theme)
|
||||
text_pen = pg.mkPen(color=palette.text().color())
|
||||
|
||||
for axis in ["left", "bottom", "right", "top"]:
|
||||
self.plot_item.getAxis(axis).setPen(text_pen)
|
||||
self.plot_item.getAxis(axis).setTextPen(text_pen)
|
||||
if self.plot_item.legend is not None:
|
||||
for sample, label in self.plot_item.legend.items:
|
||||
label.setText(label.text, color=palette.text().color())
|
||||
|
||||
def set(self, **kwargs) -> None:
|
||||
"""
|
||||
Set the properties of the plot widget.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for the properties to be set.
|
||||
|
||||
Possible properties:
|
||||
- title: str
|
||||
- x_label: str
|
||||
- y_label: str
|
||||
- x_scale: Literal["linear", "log"]
|
||||
- y_scale: Literal["linear", "log"]
|
||||
- x_lim: tuple
|
||||
- y_lim: tuple
|
||||
- legend_label_size: int
|
||||
"""
|
||||
# Mapping of keywords to setter methods
|
||||
method_map = {
|
||||
"title": self.set_title,
|
||||
"x_label": self.set_x_label,
|
||||
"y_label": self.set_y_label,
|
||||
"x_scale": self.set_x_scale,
|
||||
"y_scale": self.set_y_scale,
|
||||
"x_lim": self.set_x_lim,
|
||||
"y_lim": self.set_y_lim,
|
||||
"legend_label_size": self.set_legend_label_size,
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
if key in method_map:
|
||||
method_map[key](value)
|
||||
else:
|
||||
logger.warning(f"Warning: '{key}' is not a recognized property.")
|
||||
|
||||
def apply_axis_config(self):
|
||||
"""Apply the axis configuration to the plot widget."""
|
||||
config_mappings = {
|
||||
"title": self.config.axis.title,
|
||||
"x_label": self.config.axis.x_label,
|
||||
"y_label": self.config.axis.y_label,
|
||||
"x_scale": self.config.axis.x_scale,
|
||||
"y_scale": self.config.axis.y_scale,
|
||||
"x_lim": self.config.axis.x_lim,
|
||||
"y_lim": self.config.axis.y_lim,
|
||||
}
|
||||
|
||||
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
|
||||
|
||||
def set_legend_label_size(self, size: int = None):
|
||||
"""
|
||||
Set the font size of the legend.
|
||||
|
||||
Args:
|
||||
size(int): Font size of the legend.
|
||||
"""
|
||||
if not self.plot_item.legend:
|
||||
return
|
||||
if self.config.axis.legend_label_size or size:
|
||||
if size:
|
||||
self.config.axis.legend_label_size = size
|
||||
scale = (
|
||||
size / 9
|
||||
) # 9 is the default font size of the legend, so we always scale it against 9
|
||||
self.plot_item.legend.setScale(scale)
|
||||
|
||||
def get_text_color(self):
|
||||
return "#FFF" if self.figure.config.theme == "dark" else "#000"
|
||||
|
||||
def set_title(self, title: str, size: int = None):
|
||||
"""
|
||||
Set the title of the plot widget.
|
||||
|
||||
Args:
|
||||
title(str): Title of the plot widget.
|
||||
size(int): Font size of the title.
|
||||
"""
|
||||
if self.config.axis.title_size or size:
|
||||
if size:
|
||||
self.config.axis.title_size = size
|
||||
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
|
||||
else:
|
||||
style = {}
|
||||
self.plot_item.setTitle(title, **style)
|
||||
self.config.axis.title = title
|
||||
|
||||
def set_x_label(self, label: str, size: int = None):
|
||||
"""
|
||||
Set the label of the x-axis.
|
||||
|
||||
Args:
|
||||
label(str): Label of the x-axis.
|
||||
size(int): Font size of the label.
|
||||
"""
|
||||
if self.config.axis.x_label_size or size:
|
||||
if size:
|
||||
self.config.axis.x_label_size = size
|
||||
style = {
|
||||
"color": self.get_text_color(),
|
||||
"font-size": f"{self.config.axis.x_label_size}pt",
|
||||
}
|
||||
else:
|
||||
style = {}
|
||||
self.plot_item.setLabel("bottom", label, **style)
|
||||
self.config.axis.x_label = label
|
||||
|
||||
def set_y_label(self, label: str, size: int = None):
|
||||
"""
|
||||
Set the label of the y-axis.
|
||||
|
||||
Args:
|
||||
label(str): Label of the y-axis.
|
||||
size(int): Font size of the label.
|
||||
"""
|
||||
if self.config.axis.y_label_size or size:
|
||||
if size:
|
||||
self.config.axis.y_label_size = size
|
||||
color = self.get_text_color()
|
||||
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
|
||||
else:
|
||||
style = {}
|
||||
self.plot_item.setLabel("left", label, **style)
|
||||
self.config.axis.y_label = label
|
||||
|
||||
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
|
||||
"""
|
||||
Set the scale of the x-axis.
|
||||
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the x-axis.
|
||||
"""
|
||||
self.plot_item.setLogMode(x=(scale == "log"))
|
||||
self.config.axis.x_scale = scale
|
||||
|
||||
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
|
||||
"""
|
||||
Set the scale of the y-axis.
|
||||
|
||||
Args:
|
||||
scale(Literal["linear", "log"]): Scale of the y-axis.
|
||||
"""
|
||||
self.plot_item.setLogMode(y=(scale == "log"))
|
||||
self.config.axis.y_scale = scale
|
||||
|
||||
def set_x_lim(self, *args) -> None:
|
||||
"""
|
||||
Set the limits of the x-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum x-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_x_lim(x_min, x_max)
|
||||
set_x_lim((x_min, x_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (x_min and x_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
if len(args) == 1 and isinstance(args[0], tuple):
|
||||
x_min, x_max = args[0]
|
||||
elif len(args) == 2:
|
||||
x_min, x_max = args
|
||||
else:
|
||||
raise ValueError("set_x_lim expects either two separate arguments or a single tuple")
|
||||
|
||||
self.plot_item.setXRange(x_min, x_max)
|
||||
self.config.axis.x_lim = (x_min, x_max)
|
||||
|
||||
def set_y_lim(self, *args) -> None:
|
||||
"""
|
||||
Set the limits of the y-axis. This method can accept either two separate arguments
|
||||
for the minimum and maximum y-axis values, or a single tuple containing both limits.
|
||||
|
||||
Usage:
|
||||
set_y_lim(y_min, y_max)
|
||||
set_y_lim((y_min, y_max))
|
||||
|
||||
Args:
|
||||
*args: A variable number of arguments. Can be two integers (y_min and y_max)
|
||||
or a single tuple with two integers.
|
||||
"""
|
||||
if len(args) == 1 and isinstance(args[0], tuple):
|
||||
y_min, y_max = args[0]
|
||||
elif len(args) == 2:
|
||||
y_min, y_max = args
|
||||
else:
|
||||
raise ValueError("set_y_lim expects either two separate arguments or a single tuple")
|
||||
|
||||
self.plot_item.setYRange(y_min, y_max)
|
||||
self.config.axis.y_lim = (y_min, y_max)
|
||||
|
||||
def set_grid(self, x: bool = False, y: bool = False):
|
||||
"""
|
||||
Set the grid of the plot widget.
|
||||
|
||||
Args:
|
||||
x(bool): Show grid on the x-axis.
|
||||
y(bool): Show grid on the y-axis.
|
||||
"""
|
||||
self.plot_item.showGrid(x, y)
|
||||
self.config.axis.x_grid = x
|
||||
self.config.axis.y_grid = y
|
||||
|
||||
def set_outer_axes(self, show: bool = True):
|
||||
"""
|
||||
Set the outer axes of the plot widget.
|
||||
|
||||
Args:
|
||||
show(bool): Show the outer axes.
|
||||
"""
|
||||
self.plot_item.showAxis("top", show)
|
||||
self.plot_item.showAxis("right", show)
|
||||
self.config.axis.outer_axes = show
|
||||
|
||||
def add_legend(self):
|
||||
"""Add legend to the plot"""
|
||||
self.plot_item.addLegend()
|
||||
|
||||
def lock_aspect_ratio(self, lock):
|
||||
"""
|
||||
Lock aspect ratio.
|
||||
|
||||
Args:
|
||||
lock(bool): True to lock, False to unlock.
|
||||
"""
|
||||
self.plot_item.setAspectLocked(lock)
|
||||
|
||||
def set_auto_range(self, enabled: bool, axis: str = "xy"):
|
||||
"""
|
||||
Set the auto range of the plot widget.
|
||||
|
||||
Args:
|
||||
enabled(bool): If True, enable the auto range.
|
||||
axis(str, optional): The axis to enable the auto range.
|
||||
- "xy": Enable auto range for both x and y axis.
|
||||
- "x": Enable auto range for x axis.
|
||||
- "y": Enable auto range for y axis.
|
||||
"""
|
||||
self.plot_item.enableAutoRange(axis, enabled)
|
||||
|
||||
############################################################
|
||||
###################### Crosshair ###########################
|
||||
############################################################
|
||||
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Hook the crosshair to all plots."""
|
||||
if self.crosshair is None:
|
||||
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)
|
||||
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
|
||||
|
||||
def unhook_crosshair(self) -> None:
|
||||
"""Unhook the crosshair from all plots."""
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
|
||||
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
|
||||
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.cleanup()
|
||||
self.crosshair.deleteLater()
|
||||
self.crosshair = None
|
||||
|
||||
def toggle_crosshair(self) -> None:
|
||||
"""Toggle the crosshair on all plots."""
|
||||
if self.crosshair is None:
|
||||
return self.hook_crosshair()
|
||||
|
||||
self.unhook_crosshair()
|
||||
|
||||
@Slot()
|
||||
def reset(self) -> None:
|
||||
"""Reset the plot widget."""
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.clear_markers()
|
||||
self.crosshair.update_markers()
|
||||
|
||||
############################################################
|
||||
##################### FPS Counter ##########################
|
||||
############################################################
|
||||
|
||||
def update_fps_label(self, fps: float) -> None:
|
||||
"""
|
||||
Update the FPS label.
|
||||
|
||||
Args:
|
||||
fps(float): The frames per second.
|
||||
"""
|
||||
if self.fps_label:
|
||||
self.fps_label.setText(f"FPS: {fps:.2f}")
|
||||
|
||||
def hook_fps_monitor(self):
|
||||
"""Hook the FPS monitor to the plot."""
|
||||
if self.fps_monitor is None:
|
||||
# text_color = self.get_text_color()#TODO later
|
||||
self.fps_monitor = FPSCounter(self.plot_item.vb) # text_color=text_color)
|
||||
self.fps_label = pg.LabelItem(justify="right")
|
||||
self.addItem(self.fps_label, row=0, col=0)
|
||||
|
||||
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
|
||||
|
||||
def unhook_fps_monitor(self, delete_label=True):
|
||||
"""Unhook the FPS monitor from the plot."""
|
||||
if self.fps_monitor is not None:
|
||||
# Remove Monitor
|
||||
self.fps_monitor.cleanup()
|
||||
self.fps_monitor.deleteLater()
|
||||
self.fps_monitor = None
|
||||
if self.fps_label is not None and delete_label:
|
||||
# Remove Label
|
||||
self.removeItem(self.fps_label)
|
||||
self.fps_label.deleteLater()
|
||||
self.fps_label = None
|
||||
|
||||
def enable_fps_monitor(self, enable: bool = True):
|
||||
"""
|
||||
Enable the FPS monitor.
|
||||
|
||||
Args:
|
||||
enable(bool): True to enable, False to disable.
|
||||
"""
|
||||
if enable and self.fps_monitor is None:
|
||||
self.hook_fps_monitor()
|
||||
elif not enable and self.fps_monitor is not None:
|
||||
self.unhook_fps_monitor()
|
||||
|
||||
def export(self):
|
||||
"""Show the Export Dialog of the plot widget."""
|
||||
scene = self.plot_item.scene()
|
||||
scene.contextMenuItem = self.plot_item
|
||||
scene.showExportDialog()
|
||||
|
||||
def remove(self):
|
||||
"""Remove the plot widget from the figure."""
|
||||
if self.figure is not None:
|
||||
self.figure.remove(widget_id=self.gui_id)
|
||||
|
||||
def cleanup_pyqtgraph(self):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
self.unhook_crosshair()
|
||||
self.unhook_fps_monitor(delete_label=False)
|
||||
self.tick_item.cleanup()
|
||||
self.arrow_item.cleanup()
|
||||
item = self.plot_item
|
||||
item.vb.menu.close()
|
||||
item.vb.menu.deleteLater()
|
||||
item.ctrlMenu.close()
|
||||
item.ctrlMenu.deleteLater()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user