mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 10:10:55 +02:00
Compare commits
121 Commits
v2.0.1
...
fix/logpan
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a064efd06 | |||
| 7e2370dd45 | |||
| ca297d38ed | |||
| b225a7cc90 | |||
|
|
3d8af05688 | ||
| 0bdd4e86a2 | |||
|
|
104e4e427b | ||
| ada0977a1b | |||
|
|
1ea467c5fc | ||
| 4f69f5da45 | |||
| d8547c7a56 | |||
| 3484507c75 | |||
| 8abebb7286 | |||
|
|
1d07e88b44 | ||
| 1a4eb1db67 | |||
| f57950c4e3 | |||
| a8811c9d91 | |||
| ec740d31fd | |||
|
|
5c12ab1992 | ||
| ce88787e88 | |||
| e12e9e534d | |||
| 66e9445760 | |||
|
|
6bf4c53805 | ||
| a939c3b1c4 | |||
| 41b7ca8e64 | |||
| 7a531c17d6 | |||
| a020f2dc7e | |||
| 53377d26e2 | |||
| 05489a1c56 | |||
|
|
0dfff71e4a | ||
| d4def09a4e | |||
|
|
713653a4a5 | ||
| bcab66b187 | |||
| a345253c6e | |||
|
|
bdf33a5249 | ||
| f8276f0224 | |||
| 8227c44c33 | |||
|
|
83098d930c | ||
| a7ae856c8f | |||
|
|
06f43e4883 | ||
|
|
5ec9697271 | ||
|
|
41296b5471 | ||
| 1d018e863c | |||
| 6ee0f5004d | |||
|
|
40b5081632 | ||
| f064baae68 | |||
|
|
58f01fb3a2 | ||
| 1e344eacb7 | |||
|
|
34002fa51a | ||
| a00d510a75 | |||
|
|
120faf9523 | ||
| d7bd61f69e | |||
| 94bcfff724 | |||
| a17e7a0d52 | |||
| 7f67d28887 | |||
| 52d8e4b332 | |||
| dea2b44e6a | |||
| dc70ea6dfb | |||
| 133ddda3e3 | |||
| 8eee92e5cf | |||
|
|
85de24aa89 | ||
| 56b6a0b8c2 | |||
| d579d894f0 | |||
| d915d2f507 | |||
| 7d7a88669f | |||
| a42dcec6d4 | |||
| 8cf1f09926 | |||
| 83b153a14a | |||
| aed450ef2c | |||
| e60d0cb5ca | |||
| 01870f9cda | |||
| 483886495d | |||
| 42502f6eed | |||
| 59d87e1c2f | |||
|
|
3a5fa3d01a | ||
| dbb3a1c1fb | |||
| ca8211572f | |||
| 7584af4e44 | |||
| 95ef26565b | |||
| abbf7a7f44 | |||
| a301d37c4f | |||
| 88a17a566c | |||
| bf3746da0e | |||
| e3205d6c97 | |||
|
|
507ac10e8d | ||
| 16e167019f | |||
| d712944e6b | |||
| d9b60c6cc9 | |||
| aee83e1a9e | |||
| f5317341bf | |||
| 8345dacb26 | |||
|
|
531d9c621d | ||
| dc151cdfe3 | |||
|
|
e0dfd56a0d | ||
| 1fb680abb4 | |||
| b9e56c96cb | |||
|
|
dd956f18fe | ||
| cf59d31113 | |||
|
|
bc0e277332 | ||
| 75a2780fe0 | |||
| a6c479e42e | |||
| 64a4824054 | |||
| 1619446ec9 | |||
| 37f002427a | |||
|
|
50cb70dcc6 | ||
| 55f7efc4f5 | |||
| be72c9f270 | |||
| c8cedc0124 | |||
|
|
3fdbe4031e | ||
| c16b9dce9c | |||
| 9387275851 | |||
| 94463afdba | |||
| 02563b10f3 | |||
| fff4af2489 | |||
| 452124b528 | |||
|
|
9c84e158ba | ||
| 58a0bc7974 | |||
| 770dbd4b63 | |||
| d22035f897 | |||
|
|
fe21b39b7f | ||
| 1b78840fd8 |
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -1,3 +1,13 @@
|
||||
---
|
||||
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,3 +1,13 @@
|
||||
---
|
||||
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]
|
||||
@@ -37,4 +47,3 @@
|
||||
## 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]
|
||||
|
||||
64
.github/actions/bw_install/action.yml
vendored
Normal file
64
.github/actions/bw_install/action.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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]
|
||||
@@ -1,19 +1,24 @@
|
||||
## Description
|
||||
|
||||
[Provide a brief description of the changes introduced by this merge request.]
|
||||
[Provide a brief description of the changes introduced by this pull request.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[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`.]
|
||||
[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`.]
|
||||
|
||||
## 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 MR.]
|
||||
[Describe any potential side effects or risks of merging this PR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal file
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal file
@@ -0,0 +1,342 @@
|
||||
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()
|
||||
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pydantic
|
||||
pygithub
|
||||
28
.github/workflows/check_pr.yml
vendored
Normal file
28
.github/workflows/check_pr.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
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
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
50
.github/workflows/end2end-conda.yml
vendored
Normal file
50
.github/workflows/end2end-conda.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
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
|
||||
61
.github/workflows/formatter.yml
vendored
Normal file
61
.github/workflows/formatter.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
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 black isort
|
||||
pip install -e .[dev]
|
||||
black --check --diff --color .
|
||||
isort --check --diff ./
|
||||
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
Normal file
49
.github/workflows/generate-cli-check.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
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
Normal file
59
.github/workflows/pytest-matrix.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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
Normal file
64
.github/workflows/pytest.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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
Normal file
103
.github/workflows/semantic_release.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
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
|
||||
40
.github/workflows/sync-issues-pr.yml
vendored
Normal file
40
.github/workflows/sync-issues-pr.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
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,6 +64,9 @@ 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:
|
||||
@@ -77,7 +77,7 @@ formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install bec_lib[dev]
|
||||
- pip install -e ./[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:
|
||||
@@ -162,6 +162,20 @@ 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:
|
||||
@@ -189,7 +203,7 @@ test-matrix:
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3
|
||||
image: continuumio/miniconda3:25.1.1-2
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
@@ -216,7 +230,7 @@ end-2-end-conda:
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- pip install -e .[dev,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
|
||||
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
@@ -231,7 +245,7 @@ end-2-end-conda:
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
|
||||
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
## 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.]
|
||||
@@ -7,13 +7,13 @@ version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
python: "3.11"
|
||||
|
||||
# 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,5 +21,7 @@ sphinx:
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
path: .[dev]
|
||||
|
||||
3509
CHANGELOG.md
3509
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) 2023, bec
|
||||
Copyright (c) 2025, Paul Scherrer Institute
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
11
README.md
11
README.md
@@ -1,5 +1,16 @@
|
||||
# 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. 🚨
|
||||
|
||||
@@ -2,11 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
@@ -21,8 +21,10 @@ from qtpy.QtWidgets import (
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
@@ -35,11 +37,14 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
|
||||
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__(
|
||||
@@ -50,9 +55,15 @@ class LaunchTile(RoundedFrame):
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
tile_size: tuple[int, int] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, orientation="vertical")
|
||||
|
||||
# Provide a 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)
|
||||
@@ -83,12 +94,26 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
|
||||
# Desired default appearance
|
||||
font_main = self.main_label.font()
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setWordWrap(True)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
self.tile_size[0]
|
||||
- self.layout.contentsMargins().left()
|
||||
- self.layout.contentsMargins().right()
|
||||
)
|
||||
self._fit_label_to_width(self.main_label, content_width)
|
||||
|
||||
# Give every tile the same reserved height for the title so the
|
||||
# description labels start at an identical 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)
|
||||
@@ -129,6 +154,29 @@ class LaunchTile(RoundedFrame):
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
Fit the label text to the specified maximum width by adjusting the font size.
|
||||
|
||||
Args:
|
||||
label(QLabel): The label to adjust.
|
||||
max_width(int): The maximum width the label can occupy.
|
||||
min_pt(int): The minimum font point size to use.
|
||||
"""
|
||||
font = label.font()
|
||||
for pt in range(font.pointSize(), min_pt - 1, -1):
|
||||
font.setPointSize(pt)
|
||||
metrics = QFontMetrics(font)
|
||||
if metrics.horizontalAdvance(label.text()) <= max_width:
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
return
|
||||
# If nothing fits, fall back to eliding
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
@@ -141,6 +189,9 @@ class LaunchWindow(BECMainWindow):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
# Track the smallest 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)
|
||||
@@ -156,58 +207,125 @@ class LaunchWindow(BECMainWindow):
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.tile_dock_area = LaunchTile(
|
||||
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.tile_dock_area.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.tile_auto_update = LaunchTile(
|
||||
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.tile_auto_update.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.tile_ui_file = LaunchTile(
|
||||
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,
|
||||
)
|
||||
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
# Add tiles to the main layout
|
||||
self.central_widget.layout.addWidget(self.tile_dock_area)
|
||||
self.central_widget.layout.addWidget(self.tile_auto_update)
|
||||
self.central_widget.layout.addWidget(self.tile_ui_file)
|
||||
|
||||
# hacky solution no time to waste
|
||||
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
|
||||
|
||||
# Connect signals
|
||||
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
|
||||
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
|
||||
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
|
||||
self._update_theme()
|
||||
|
||||
# Auto updates
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
if self.tile_auto_update.selector is not None:
|
||||
self.tile_auto_update.selector.addItems(
|
||||
list(self.available_auto_updates.keys()) + ["Default"]
|
||||
# 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,
|
||||
@@ -235,10 +353,8 @@ class LaunchWindow(BECMainWindow):
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(
|
||||
f"Name {name} contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
@@ -258,6 +374,12 @@ class LaunchWindow(BECMainWindow):
|
||||
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.")
|
||||
@@ -275,6 +397,7 @@ class LaunchWindow(BECMainWindow):
|
||||
else:
|
||||
window = BECMainWindow()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
@@ -284,6 +407,8 @@ class LaunchWindow(BECMainWindow):
|
||||
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
|
||||
@@ -321,11 +446,28 @@ class LaunchWindow(BECMainWindow):
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
window = 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:
|
||||
for tile in self.tiles.values():
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
@@ -334,14 +476,25 @@ class LaunchWindow(BECMainWindow):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tile_auto_update.selector is None:
|
||||
if self.tiles["auto_update"].selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tile_auto_update.selector.currentText()
|
||||
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):
|
||||
"""
|
||||
|
||||
BIN
bec_widgets/assets/app_icons/widget_launch_tile.png
Normal file
BIN
bec_widgets/assets/app_icons/widget_launch_tile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -7,6 +7,7 @@ import enum
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -50,16 +51,19 @@ _Widgets = {
|
||||
"RingProgressBar": "RingProgressBar",
|
||||
"ScanControl": "ScanControl",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
"SignalLineEdit": "SignalLineEdit",
|
||||
"StopButton": "StopButton",
|
||||
"TextBox": "TextBox",
|
||||
"VSCodeEditor": "VSCodeEditor",
|
||||
"Waveform": "Waveform",
|
||||
"WebConsole": "WebConsole",
|
||||
"WebsiteWidget": "WebsiteWidget",
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
_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)
|
||||
|
||||
@@ -469,6 +473,12 @@ class BECProgressBar(RPCBase):
|
||||
>>> progressbar.label_template = "$value / $percentage %"
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def _get_label(self) -> str:
|
||||
"""
|
||||
Return the label text. mostly used for testing rpc.
|
||||
"""
|
||||
|
||||
|
||||
class BECQueue(RPCBase):
|
||||
"""Widget to display the BEC queue."""
|
||||
@@ -483,6 +493,12 @@ class BECQueue(RPCBase):
|
||||
class BECStatusBox(RPCBase):
|
||||
"""An autonomous widget to display the status of BEC services."""
|
||||
|
||||
@rpc_call
|
||||
def get_server_state(self) -> "str":
|
||||
"""
|
||||
Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
@@ -490,6 +506,224 @@ class BECStatusBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BaseROI(RPCBase):
|
||||
"""Base class for all Region of Interest (ROI) implementations."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self):
|
||||
"""
|
||||
Gets the coordinates that define this ROI's position and shape.
|
||||
|
||||
This is an abstract method that must be implemented by subclasses.
|
||||
Implementations should return either a dictionary with descriptive keys
|
||||
or a tuple of coordinates, depending on the value of self.description.
|
||||
|
||||
Returns:
|
||||
dict or tuple: The coordinates defining the ROI's position and shape.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: This method must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_position(self, x: "float", y: "float"):
|
||||
"""
|
||||
Sets the position of the ROI.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate of the new position.
|
||||
y (float): The y-coordinate of the new position.
|
||||
"""
|
||||
|
||||
|
||||
class CircularROI(RPCBase):
|
||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Calculates and returns the coordinates and size of an object, either as a
|
||||
typed dictionary or as a tuple.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
|
||||
to None, which utilizes the object's description value.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
|
||||
if `typed` is True.
|
||||
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_position(self, x: "float", y: "float"):
|
||||
"""
|
||||
Sets the position of the ROI.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate of the new position.
|
||||
y (float): The y-coordinate of the new position.
|
||||
"""
|
||||
|
||||
|
||||
class Curve(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
@@ -707,9 +941,22 @@ class DeviceComboBox(RPCBase):
|
||||
"""Combobox widget for device input with autocomplete for device names."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
def set_device(self, device: "str"):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
Set the device.
|
||||
|
||||
Args:
|
||||
device (str): Default name.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def devices(self) -> "list[str]":
|
||||
"""
|
||||
Get the list of devices for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of devices.
|
||||
"""
|
||||
|
||||
|
||||
@@ -727,9 +974,32 @@ class DeviceLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
def set_device(self, device: "str"):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
Set the device.
|
||||
|
||||
Args:
|
||||
device (str): Default name.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def devices(self) -> "list[str]":
|
||||
"""
|
||||
Get the list of devices for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of devices.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _is_valid_input(self) -> bool:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the current value is a valid device name, False otherwise.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1201,6 +1471,44 @@ class Image(RPCBase):
|
||||
Access the main image item.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_roi(
|
||||
self,
|
||||
kind: "Literal['rect', 'circle']" = "rect",
|
||||
name: "str | None" = None,
|
||||
line_width: "int | None" = 5,
|
||||
pos: "tuple[float, float] | None" = (10, 10),
|
||||
size: "tuple[float, float] | None" = (50, 50),
|
||||
**pg_kwargs,
|
||||
) -> "RectangularROI | CircularROI":
|
||||
"""
|
||||
Add a ROI to the image.
|
||||
|
||||
Args:
|
||||
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
||||
name(str): The name of the ROI.
|
||||
line_width(int): The line width of the ROI.
|
||||
pos(tuple): The position of the ROI.
|
||||
size(tuple): The size of the ROI.
|
||||
**pg_kwargs: Additional arguments for the ROI.
|
||||
|
||||
Returns:
|
||||
RectangularROI | CircularROI: The created ROI object.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove_roi(self, roi: "int | str"):
|
||||
"""
|
||||
Remove an ROI by index or label via the ROIController.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rois(self) -> "list[BaseROI]":
|
||||
"""
|
||||
Get the list of ROIs.
|
||||
"""
|
||||
|
||||
|
||||
class ImageItem(RPCBase):
|
||||
@property
|
||||
@@ -1317,14 +1625,14 @@ class ImageItem(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def rotation(self) -> "Optional[int]":
|
||||
def num_rotation_90(self) -> "Optional[int]":
|
||||
"""
|
||||
Get or set the number of 90° rotations to apply.
|
||||
"""
|
||||
|
||||
@rotation.setter
|
||||
@num_rotation_90.setter
|
||||
@rpc_call
|
||||
def rotation(self) -> "Optional[int]":
|
||||
def num_rotation_90(self) -> "Optional[int]":
|
||||
"""
|
||||
Get or set the number of 90° rotations to apply.
|
||||
"""
|
||||
@@ -2304,6 +2612,115 @@ class PositionerGroup(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class RectangularROI(RPCBase):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@label.setter
|
||||
@rpc_call
|
||||
def label(self) -> "str":
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@line_color.setter
|
||||
@rpc_call
|
||||
def line_color(self) -> "str":
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@line_width.setter
|
||||
@rpc_call
|
||||
def line_width(self) -> "int":
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
descriptive keys. If False, returns them as a tuple. Defaults to
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_data_from_image(
|
||||
self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs
|
||||
):
|
||||
"""
|
||||
Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_position(self, x: "float", y: "float"):
|
||||
"""
|
||||
Sets the position of the ROI.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate of the new position.
|
||||
y (float): The y-coordinate of the new position.
|
||||
"""
|
||||
|
||||
|
||||
class ResetButton(RPCBase):
|
||||
"""A button that resets the scan queue."""
|
||||
|
||||
@@ -2968,6 +3385,80 @@ class ScatterWaveform(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class SignalComboBox(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
|
||||
@rpc_call
|
||||
def set_signal(self, signal: str):
|
||||
"""
|
||||
Set the signal.
|
||||
|
||||
Args:
|
||||
signal (str): signal name.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. If device is not valid, device will be set to None which happens
|
||||
|
||||
Args:
|
||||
device(str): device name.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signals(self) -> list[str]:
|
||||
"""
|
||||
Get the list of device signals for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of device signals.
|
||||
"""
|
||||
|
||||
|
||||
class SignalLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _is_valid_input(self) -> bool:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the current value is a valid device name, False otherwise.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_signal(self, signal: str):
|
||||
"""
|
||||
Set the signal.
|
||||
|
||||
Args:
|
||||
signal (str): signal name.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. If device is not valid, device will be set to None which happens
|
||||
|
||||
Args:
|
||||
device(str): device name.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def signals(self) -> list[str]:
|
||||
"""
|
||||
Get the list of device signals for the applied filters.
|
||||
|
||||
Returns:
|
||||
list[str]: List of device signals.
|
||||
"""
|
||||
|
||||
|
||||
class StopButton(RPCBase):
|
||||
"""A button that stops the current scan."""
|
||||
|
||||
@@ -3488,6 +3979,16 @@ class Waveform(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class WebConsole(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
|
||||
class WebsiteWidget(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class ClientGenerator:
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
@@ -110,7 +111,7 @@ _Widgets = {
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
_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)
|
||||
|
||||
@@ -222,18 +223,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.FileMode(line_length=100))
|
||||
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
isort.Config(
|
||||
config = isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=True,
|
||||
include_trailing_comma=False,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
@@ -318,5 +319,5 @@ def main():
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
sys.argv = ["bw-generate-cli", "--target", "csaxs_bec"]
|
||||
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
|
||||
main()
|
||||
|
||||
@@ -31,10 +31,9 @@ class RPCWidgetHandler:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = get_all_plugin_widgets() | {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,6 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
@@ -38,6 +37,10 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"pg": pg,
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
# "im": self.im,
|
||||
"im": self.im,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
@@ -112,13 +112,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
# tab_widget.setCurrentIndex(4)
|
||||
#
|
||||
# sixth_tab = QWidget()
|
||||
# sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
# self.im = Image()
|
||||
# self.mi = self.im.main_image
|
||||
# sixth_tab_layout.addWidget(self.im)
|
||||
# tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
# tab_widget.setCurrentIndex(5)
|
||||
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)
|
||||
|
||||
@@ -96,9 +96,9 @@ class FakePositioner(BECPositioner):
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {"kind_str": "5"}, # hinted
|
||||
"setpoint": {"kind_str": "1"}, # normal
|
||||
"velocity": {"kind_str": "2"}, # config
|
||||
"readback": {"kind_str": "hinted"}, # hinted
|
||||
"setpoint": {"kind_str": "normal"}, # normal
|
||||
"velocity": {"kind_str": "config"}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
@@ -200,7 +200,13 @@ class DMMock:
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devives(self, devices: list):
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
|
||||
@@ -205,6 +205,17 @@ class BECConnector:
|
||||
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.
|
||||
|
||||
@@ -16,9 +16,9 @@ if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ def list_editable_packages() -> set[str]:
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(): # pragma: no cover
|
||||
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
@@ -119,7 +119,7 @@ def patch_designer(): # 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"), sys.argv[1:])
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def main(): # pragma: no cover
|
||||
def open_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
@@ -160,7 +160,11 @@ def main(): # pragma: no cover
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
patch_designer(cmd_args)
|
||||
|
||||
|
||||
def main():
|
||||
open_designer(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -4,8 +4,9 @@ import collections
|
||||
import random
|
||||
import string
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -25,21 +26,41 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
|
||||
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):
|
||||
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.
|
||||
"""
|
||||
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 id(self.cb)
|
||||
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
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
if self.cb_ref() is None:
|
||||
# callback has been deleted
|
||||
return
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
@@ -86,7 +107,7 @@ class BECDispatcher:
|
||||
cls,
|
||||
client=None,
|
||||
config: str | ServiceConfig | None = None,
|
||||
gui_id: str = None,
|
||||
gui_id: str | None = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -99,7 +120,9 @@ class BECDispatcher:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._slots = collections.defaultdict(set)
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
@@ -141,6 +164,7 @@ class BECDispatcher:
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
cb_info: dict | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
@@ -149,11 +173,15 @@ class BECDispatcher:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
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)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
"""
|
||||
@@ -166,16 +194,16 @@ class BECDispatcher:
|
||||
# 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._slots:
|
||||
for connected_slot in self._registered_slots.values():
|
||||
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._slots[connected_slot].difference_update(set(topics_str))
|
||||
if not self._slots[connected_slot]:
|
||||
del self._slots[connected_slot]
|
||||
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]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
@@ -186,11 +214,16 @@ class BECDispatcher:
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
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]
|
||||
|
||||
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)
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -3,12 +3,17 @@ 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_widgets.utils.bec_widget import BECWidget
|
||||
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, ...]:
|
||||
@@ -21,7 +26,7 @@ def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
|
||||
|
||||
def _loaded_submodules_from_specs(
|
||||
submodule_specs: tuple[ModuleSpec | None, ...]
|
||||
submodule_specs: tuple[ModuleSpec | None, ...],
|
||||
) -> Generator[ModuleType, None, None]:
|
||||
"""Load all submodules from the given specs."""
|
||||
for submodule in (
|
||||
@@ -30,7 +35,12 @@ def _loaded_submodules_from_specs(
|
||||
assert isinstance(
|
||||
submodule.__loader__, SourceFileLoader
|
||||
), "Module found from FileFinder should have SourceFileLoader!"
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
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
|
||||
|
||||
|
||||
@@ -41,27 +51,29 @@ def _submodule_by_name(module: ModuleType, name: str):
|
||||
return None
|
||||
|
||||
|
||||
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their names."""
|
||||
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
|
||||
|
||||
return dict(
|
||||
inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget,
|
||||
)
|
||||
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):
|
||||
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.update(_all_widgets_from_all_submods(submod))
|
||||
widgets += _all_widgets_from_all_submods(submod)
|
||||
return widgets
|
||||
|
||||
|
||||
@@ -75,15 +87,16 @@ def get_plugin_client_module() -> ModuleType | None:
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
|
||||
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 {}
|
||||
return BECClassContainer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# print(get_all_plugin_widgets())
|
||||
|
||||
client = get_plugin_client_module()
|
||||
print(get_all_plugin_widgets())
|
||||
...
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Literal, Type
|
||||
from typing import Any, Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
@@ -73,3 +72,36 @@ 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.")
|
||||
|
||||
@@ -85,7 +85,8 @@ class Crosshair(QObject):
|
||||
self.items = []
|
||||
self.marker_moved_1d = {}
|
||||
self.marker_clicked_1d = {}
|
||||
self.marker_2d = None
|
||||
self.marker_2d_row = None
|
||||
self.marker_2d_col = None
|
||||
self.update_markers()
|
||||
self.check_log()
|
||||
self.check_derivatives()
|
||||
@@ -195,13 +196,23 @@ 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 is not None:
|
||||
if self.marker_2d_row is not None and self.marker_2d_col is not None:
|
||||
continue
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
# 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.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
self.marker_2d_row.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d_row)
|
||||
|
||||
# Create vertical ROI for column highlighting
|
||||
self.marker_2d_col = pg.ROI(
|
||||
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.marker_2d_col.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d_col)
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
@@ -243,6 +254,8 @@ class Crosshair(QObject):
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor or str(id(item))
|
||||
image_2d = item.image
|
||||
if image_2d is None:
|
||||
continue
|
||||
# Clip the x and y values to the image dimensions to avoid out of bounds errors
|
||||
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
@@ -330,7 +343,10 @@ class Crosshair(QObject):
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
# 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])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
@@ -384,7 +400,10 @@ class Crosshair(QObject):
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
# 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])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
@@ -428,6 +447,8 @@ class Crosshair(QObject):
|
||||
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]
|
||||
@@ -450,9 +471,12 @@ class Crosshair(QObject):
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
if self.marker_2d is not None:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
self.marker_2d = None
|
||||
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,13 +17,23 @@ class EntryValidator:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
# 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]
|
||||
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
if entry not in available_entries:
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
|
||||
f"Entry '{entry}' not found in device '{name}' signals. "
|
||||
f"Available signals: '{available_entries}'"
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
@@ -99,16 +99,30 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
'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.
|
||||
|
||||
"""
|
||||
popup_error = bool(slot_kwargs.pop("popup_error", False))
|
||||
verify_sender = bool(slot_kwargs.pop("verify_sender", False))
|
||||
_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)),
|
||||
}
|
||||
|
||||
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 verify_sender or len(args) == 0:
|
||||
if not _slot_params["verify_sender"] or len(args) == 0:
|
||||
return method(*args, **kwargs)
|
||||
|
||||
_instance = args[0]
|
||||
@@ -126,11 +140,11 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
except Exception:
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=popup_error
|
||||
)
|
||||
if _slot_params["popup_error"]:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
if _slot_params["raise_error"]:
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -1,5 +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. """
|
||||
"""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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
@@ -90,15 +90,15 @@ class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type
|
||||
obj: type[BECWidget]
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_plugin: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection: list[BECClassInfo] = []
|
||||
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))
|
||||
@@ -106,6 +106,16 @@ class BECClassContainer:
|
||||
def __iter__(self):
|
||||
return self._collection.__iter__()
|
||||
|
||||
def __add__(self, other: BECClassContainer):
|
||||
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
|
||||
|
||||
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
|
||||
"""get a dict of {name: Type} for all the entries in the collection.
|
||||
|
||||
Args:
|
||||
ignores(list[str]): a list of class names to exclude from the dictionary."""
|
||||
return {c.name: c.obj for c in self if c.name not in ignores}
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
@@ -115,53 +125,44 @@ class BECClassContainer:
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
"""Return a list of class names"""
|
||||
return [c.name for c in self]
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
"""Get the collection of classes."""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
"""Get all connector classes."""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
"""Get all top-level classes."""
|
||||
return [info.obj for info in self.collection if info.is_plugin]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
"""Get all plugins. These are all classes that are on the top level and are widgets."""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
"""Get all widgets. These are all classes inheriting from BECWidget."""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
|
||||
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
"""
|
||||
Get all classes.
|
||||
"""
|
||||
"""Get all classes."""
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
@@ -197,7 +198,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):
|
||||
|
||||
@@ -31,6 +31,7 @@ class SidePanel(QWidget):
|
||||
panel_max_width: int = 200,
|
||||
animation_duration: int = 200,
|
||||
animations_enabled: bool = True,
|
||||
show_toolbar: bool = True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
@@ -40,6 +41,7 @@ class SidePanel(QWidget):
|
||||
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
|
||||
@@ -71,13 +73,14 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setMinimumWidth(5)
|
||||
self.stack_widget.setMaximumWidth(self._panel_max_width)
|
||||
|
||||
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 in ("left", "right"):
|
||||
if self._show_toolbar:
|
||||
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")
|
||||
@@ -102,11 +105,13 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setMaximumHeight(self._panel_max_width)
|
||||
|
||||
if self._orientation == "top":
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
if self._show_toolbar:
|
||||
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._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
@@ -233,21 +238,24 @@ class SidePanel(QWidget):
|
||||
|
||||
def add_menu(
|
||||
self,
|
||||
action_id: str,
|
||||
icon_name: str,
|
||||
tooltip: str,
|
||||
widget: QWidget,
|
||||
action_id: str | None = None,
|
||||
icon_name: str | None = None,
|
||||
tooltip: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
) -> int:
|
||||
"""
|
||||
Add a menu to the side panel.
|
||||
|
||||
Args:
|
||||
action_id(str): The ID of the action.
|
||||
icon_name(str): The name of the icon.
|
||||
tooltip(str): The tooltip for the action.
|
||||
widget(QWidget): The widget to add to the panel.
|
||||
title(str): The title of 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().
|
||||
"""
|
||||
# container_widget: top-level container for the stacked page
|
||||
container_widget = QWidget()
|
||||
@@ -278,32 +286,35 @@ class SidePanel(QWidget):
|
||||
index = self.stack_widget.count()
|
||||
self.stack_widget.addWidget(container_widget)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
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)
|
||||
if not self.panel_visible:
|
||||
self.show_panel(index)
|
||||
else:
|
||||
self.switch_to(index)
|
||||
else:
|
||||
self.switch_to(index)
|
||||
else:
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
|
||||
return index
|
||||
|
||||
|
||||
############################################
|
||||
@@ -332,41 +343,56 @@ class ExampleApp(QMainWindow): # pragma: no cover
|
||||
self.add_side_menus()
|
||||
|
||||
def add_side_menus(self):
|
||||
# Example 1: With action, icon, and tooltip
|
||||
widget1 = QWidget()
|
||||
layout1 = QVBoxLayout(widget1)
|
||||
for i in range(15):
|
||||
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget1,
|
||||
action_id="widget1",
|
||||
icon_name="counter_1",
|
||||
tooltip="Show Widget 1",
|
||||
widget=widget1,
|
||||
title="Widget 1 Panel",
|
||||
)
|
||||
|
||||
# Example 2: With action, icon, and tooltip
|
||||
widget2 = QWidget()
|
||||
layout2 = QVBoxLayout(widget2)
|
||||
layout2.addWidget(QLabel("Short widget 2 content"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget2,
|
||||
action_id="widget2",
|
||||
icon_name="counter_2",
|
||||
tooltip="Show Widget 2",
|
||||
widget=widget2,
|
||||
title="Widget 2 Panel",
|
||||
)
|
||||
|
||||
# Example 3: With action, icon, and tooltip
|
||||
widget3 = QWidget()
|
||||
layout3 = QVBoxLayout(widget3)
|
||||
for i in range(10):
|
||||
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget3,
|
||||
action_id="widget3",
|
||||
icon_name="counter_3",
|
||||
tooltip="Show Widget 3",
|
||||
widget=widget3,
|
||||
title="Widget 3 Panel",
|
||||
)
|
||||
|
||||
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
|
||||
widget4 = QWidget()
|
||||
layout4 = QVBoxLayout(widget4)
|
||||
layout4.addWidget(QLabel("This panel has no toolbar button"))
|
||||
layout4.addWidget(QLabel("It can only be shown programmatically"))
|
||||
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
|
||||
|
||||
# Example of how to show the hidden panel programmatically after 3 seconds
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -31,6 +32,8 @@ 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)
|
||||
|
||||
@@ -173,6 +176,10 @@ 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.setCheckable(self.checkable)
|
||||
|
||||
@@ -702,6 +709,85 @@ 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.
|
||||
|
||||
@@ -2,6 +2,7 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy import PYQT6, PYSIDE6
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
@@ -30,9 +31,9 @@ class UILoader:
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
widgets = get_custom_classes("bec_widgets").classes
|
||||
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
self.custom_widgets = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict()
|
||||
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
|
||||
@@ -303,11 +303,7 @@ class BECDock(BECWidget, Dock):
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if name is not None:
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(
|
||||
f"Name {name} contains invalid characters. "
|
||||
f"Only alphanumeric characters and underscores are allowed."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
|
||||
@@ -163,8 +163,11 @@ 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", filled=True
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -230,9 +233,11 @@ class BECDockArea(BECWidget, QWidget):
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
)
|
||||
# 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")
|
||||
# )
|
||||
|
||||
# Icons
|
||||
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
|
||||
@@ -366,11 +371,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
f"Name {name} must be unique for docks, but already exists in DockArea "
|
||||
f"with name: {self.object_name} and id {self.gui_id}."
|
||||
)
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(
|
||||
f"Name {name} contains invalid characters. "
|
||||
f"Only alphanumeric characters and underscores are allowed."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class LayoutManagerWidget(QWidget):
|
||||
self,
|
||||
widget: QWidget | str,
|
||||
row: int | None = None,
|
||||
col: Optional[int] = None,
|
||||
col: int | None = None,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift_existing: bool = True,
|
||||
@@ -138,6 +138,39 @@ class LayoutManagerWidget(QWidget):
|
||||
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
|
||||
|
||||
# Determine new widget position based on the specified relative position
|
||||
|
||||
# If adding to the left or right with shifting, shift the entire column
|
||||
if (
|
||||
position in ("left", "right")
|
||||
and shift_existing
|
||||
and shift_direction in ("left", "right")
|
||||
):
|
||||
column = ref_col
|
||||
# Collect all rows in this column and sort for safe shifting
|
||||
rows = sorted(
|
||||
{row for (row, col) in self.position_widgets.keys() if col == column},
|
||||
reverse=(shift_direction == "right"),
|
||||
)
|
||||
# Shift each widget in the column
|
||||
for r in rows:
|
||||
self.shift_widgets(direction=shift_direction, start_row=r, start_col=column)
|
||||
# Update reference widget's position after the column shift
|
||||
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
|
||||
new_row = ref_row
|
||||
# Compute insertion column based on relative position
|
||||
if position == "left":
|
||||
new_col = ref_col - ref_colspan
|
||||
else:
|
||||
new_col = ref_col + ref_colspan
|
||||
# Add the new widget without triggering another shift
|
||||
return self.add_widget(
|
||||
widget=widget,
|
||||
row=new_row,
|
||||
col=new_col,
|
||||
rowspan=rowspan,
|
||||
colspan=colspan,
|
||||
shift_existing=False,
|
||||
)
|
||||
if position == "left":
|
||||
new_row = ref_row
|
||||
new_col = ref_col - 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" Module for a PositionerGroup widget to control a positioner device."""
|
||||
"""Module for a PositionerGroup widget to control a positioner device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -397,7 +397,7 @@ class DeviceInputBase(BECWidget):
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
self.validate_device(device)
|
||||
dev = getattr(self.dev, device.lower(), None)
|
||||
dev = getattr(self.dev, device, None)
|
||||
if dev is None:
|
||||
raise ValueError(
|
||||
f"Device {device} is not found in the device manager {self.dev} as enabled device."
|
||||
|
||||
@@ -36,14 +36,16 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Kind.config: "include_config_signals",
|
||||
}
|
||||
|
||||
def __init__(self, client=None, config=None, gui_id: str = None, **kwargs):
|
||||
if config is None:
|
||||
config = DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
|
||||
else:
|
||||
if isinstance(config, dict):
|
||||
config = DeviceSignalInputBaseConfig(**config)
|
||||
self.config = config
|
||||
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
config: DeviceSignalInputBaseConfig | dict | None = None,
|
||||
gui_id: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
self.config = self._process_config_input(config)
|
||||
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
|
||||
|
||||
self._device = None
|
||||
self.get_bec_shortcuts()
|
||||
@@ -77,7 +79,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@Slot(str)
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. If device is not valid, device will be set to None which happpens
|
||||
Set the device. If device is not valid, device will be set to None which happens
|
||||
|
||||
Args:
|
||||
device(str): device name.
|
||||
@@ -102,10 +104,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
"""
|
||||
self.config.signal_filter = self.signal_filter
|
||||
# pylint: disable=protected-access
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
if self.validate_device(self._device) is False:
|
||||
if not self.validate_device(self._device):
|
||||
self._device = None
|
||||
self.config.device = self._device
|
||||
return
|
||||
@@ -113,30 +112,25 @@ class DeviceSignalInputBase(BECWidget):
|
||||
# See above convention for Signals and ComputedSignals
|
||||
if isinstance(device, Signal):
|
||||
self._signals = [self._device]
|
||||
FilterIO.set_selection(widget=self, selection=[self._device])
|
||||
self._hinted_signals = [self._device]
|
||||
self._normal_signals = []
|
||||
self._config_signals = []
|
||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
||||
return
|
||||
device_info = device._info["signals"]
|
||||
if Kind.hinted in self.signal_filter:
|
||||
hinted_signals = [
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
def _update(kind: Kind):
|
||||
return [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if (signal_info.get("kind_str", None) == str(Kind.hinted.value))
|
||||
if kind in self.signal_filter
|
||||
and (signal_info.get("kind_str", None) == str(kind.name))
|
||||
]
|
||||
self._hinted_signals = hinted_signals
|
||||
if Kind.normal in self.signal_filter:
|
||||
normal_signals = [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if (signal_info.get("kind_str", None) == str(Kind.normal.value))
|
||||
]
|
||||
self._normal_signals = normal_signals
|
||||
if Kind.config in self.signal_filter:
|
||||
config_signals = [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if (signal_info.get("kind_str", None) == str(Kind.config.value))
|
||||
]
|
||||
self._config_signals = config_signals
|
||||
|
||||
self._hinted_signals = _update(Kind.hinted)
|
||||
self._normal_signals = _update(Kind.normal)
|
||||
self._config_signals = _update(Kind.config)
|
||||
|
||||
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
|
||||
FilterIO.set_selection(widget=self, selection=self.signals)
|
||||
|
||||
@@ -250,7 +244,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
||||
"""
|
||||
self.validate_device(device)
|
||||
dev = getattr(self.dev, device.lower(), None)
|
||||
dev = getattr(self.dev, device, None)
|
||||
if dev is None:
|
||||
logger.warning(f"Device {device} not found in devicemanager.")
|
||||
return None
|
||||
@@ -279,3 +273,8 @@ class DeviceSignalInputBase(BECWidget):
|
||||
if signal in self.signals:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
|
||||
if config is None:
|
||||
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
|
||||
return DeviceSignalInputBaseConfig.model_validate(config)
|
||||
|
||||
@@ -22,10 +22,14 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_device", "devices"]
|
||||
|
||||
ICON_NAME = "list_alt"
|
||||
PLUGIN = True
|
||||
|
||||
@@ -140,7 +144,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
"""
|
||||
if self.validate_device(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text.lower())
|
||||
self.device_selected.emit(input_text)
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.update()
|
||||
|
||||
@@ -24,11 +24,15 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and ReadoutPriority. Check DeviceInputBase for more details.
|
||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_device", "devices", "_is_valid_input"]
|
||||
|
||||
device_selected = Signal(str)
|
||||
device_config_update = Signal()
|
||||
|
||||
@@ -51,7 +55,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
**kwargs,
|
||||
):
|
||||
self._callback_id = None
|
||||
self._is_valid_input = False
|
||||
self.__is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.completer = QCompleter(self)
|
||||
@@ -95,6 +99,20 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
self.textChanged.connect(self.check_validity)
|
||||
self.check_validity(self.text())
|
||||
|
||||
@property
|
||||
def _is_valid_input(self) -> bool:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the current value is a valid device name, False otherwise.
|
||||
"""
|
||||
return self.__is_valid_input
|
||||
|
||||
@_is_valid_input.setter
|
||||
def _is_valid_input(self, value: bool) -> None:
|
||||
self.__is_valid_input = value
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
@@ -147,7 +165,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
||||
"""
|
||||
if self.validate_device(input_text) is True:
|
||||
self._is_valid_input = True
|
||||
self.device_selected.emit(input_text.lower())
|
||||
self.device_selected.emit(input_text)
|
||||
else:
|
||||
self._is_valid_input = False
|
||||
self.update()
|
||||
|
||||
@@ -23,8 +23,11 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["set_signal", "set_device", "signals"]
|
||||
|
||||
ICON_NAME = "list_alt"
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
|
||||
|
||||
@@ -24,9 +24,12 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"]
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "vital_signs"
|
||||
|
||||
def __init__(
|
||||
@@ -41,7 +44,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
arg_name: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._is_valid_input = False
|
||||
self.__is_valid_input = False
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.completer = QCompleter(self)
|
||||
@@ -65,8 +68,22 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
||||
self.set_device(device)
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
self.textChanged.connect(self.validate_device)
|
||||
self.validate_device(self.text())
|
||||
self.textChanged.connect(self.check_validity)
|
||||
self.check_validity(self.text())
|
||||
|
||||
@property
|
||||
def _is_valid_input(self) -> bool:
|
||||
"""
|
||||
Check if the current value is a valid device name.
|
||||
|
||||
Returns:
|
||||
bool: True if the current value is a valid device name, False otherwise.
|
||||
"""
|
||||
return self.__is_valid_input
|
||||
|
||||
@_is_valid_input.setter
|
||||
def _is_valid_input(self, value: bool) -> None:
|
||||
self.__is_valid_input = value
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""
|
||||
@@ -131,6 +148,9 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
DeviceComboBox,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
@@ -138,6 +158,12 @@ if __name__ == "__main__": # pragma: no cover
|
||||
widget.setFixedSize(200, 200)
|
||||
layout = QVBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
layout.addWidget(SignalLineEdit(device="samx"))
|
||||
device_line_edit = DeviceComboBox()
|
||||
device_line_edit.filter_to_positioner = True
|
||||
signal_line_edit = SignalLineEdit()
|
||||
device_line_edit.device_selected.connect(signal_line_edit.set_device)
|
||||
|
||||
layout.addWidget(device_line_edit)
|
||||
layout.addWidget(signal_line_edit)
|
||||
widget.show()
|
||||
app.exec_()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
|
||||
"""Module for DapComboBox widget class to select a DAP model from a combobox."""
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell.
|
||||
BECConsole is a Qt widget that runs a Bash shell.
|
||||
|
||||
BECConsole VT100 emulation is powered by Pyte,
|
||||
(https://github.com/selectel/pyte).
|
||||
@@ -56,12 +56,12 @@ control_keys_mapping = {
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
|
||||
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
@@ -72,10 +72,10 @@ control_keys_mapping = {
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
|
||||
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
@@ -89,7 +89,7 @@ normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0E",
|
||||
QtCore.Qt.Key_Down: b"\x0e",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
|
||||
@@ -53,6 +53,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
if value in self._disallowed_keys or value in self._other_keys(index.row()):
|
||||
return False
|
||||
self._data[index.row()][index.column()] = str(value)
|
||||
self.dataChanged.emit(index, index)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -109,6 +110,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
|
||||
class DictBackedTable(QWidget):
|
||||
delete_rows = Signal(list)
|
||||
data_updated = Signal()
|
||||
|
||||
def __init__(self, initial_data: list[list[str]]):
|
||||
"""Widget which uses a DictBackedTableModel to display an editable table
|
||||
@@ -141,6 +143,11 @@ class DictBackedTable(QWidget):
|
||||
self._add_button.clicked.connect(self._table_model.add_row)
|
||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||
self.delete_rows.connect(self._table_model.delete_rows)
|
||||
self._table_model.dataChanged.connect(self._emit_data_updated)
|
||||
|
||||
def _emit_data_updated(self, *args, **kwargs):
|
||||
"""Just to swallow the args"""
|
||||
self.data_updated.emit()
|
||||
|
||||
def delete_selected_rows(self):
|
||||
"""Delete rows which are part of the selection model"""
|
||||
|
||||
@@ -43,6 +43,7 @@ class ScanMetadata(PydanticModelForm):
|
||||
self._additional_metadata = DictBackedTable(initial_extras or [])
|
||||
self._scan_name = scan_name or ""
|
||||
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
|
||||
self._additional_metadata.data_updated.connect(self.validate_form)
|
||||
|
||||
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
230
bec_widgets/widgets/editors/web_console/web_console.py
Normal file
230
bec_widgets/widgets/editors/web_console/web_console.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import QUrl, qInstallMessageHandler
|
||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class WebConsoleRegistry:
|
||||
"""
|
||||
A registry for the WebConsole class to manage its instances.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the registry.
|
||||
"""
|
||||
self._instances = {}
|
||||
self._server_process = None
|
||||
self._server_port = None
|
||||
self._token = secrets.token_hex(16)
|
||||
|
||||
def register(self, instance: WebConsole):
|
||||
"""
|
||||
Register an instance of WebConsole.
|
||||
"""
|
||||
self._instances[instance.gui_id] = safe_ref(instance)
|
||||
self.cleanup()
|
||||
|
||||
if self._server_process is None:
|
||||
# Start the ttyd server if not already running
|
||||
self.start_ttyd()
|
||||
|
||||
def start_ttyd(self, use_zsh: bool | None = None):
|
||||
"""
|
||||
Start the ttyd server
|
||||
ttyd -q -W -t 'theme={"background": "black"}' zsh
|
||||
|
||||
Args:
|
||||
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
|
||||
"""
|
||||
|
||||
# First, check if ttyd is installed
|
||||
try:
|
||||
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
|
||||
except FileNotFoundError:
|
||||
# pylint: disable=raise-missing-from
|
||||
raise RuntimeError("ttyd is not installed. Please install it first.")
|
||||
|
||||
if use_zsh is None:
|
||||
# Check if we can use zsh
|
||||
try:
|
||||
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
|
||||
use_zsh = True
|
||||
except FileNotFoundError:
|
||||
use_zsh = False
|
||||
|
||||
command = [
|
||||
"ttyd",
|
||||
"-p",
|
||||
"0",
|
||||
"-W",
|
||||
"-t",
|
||||
'theme={"background": "black"}',
|
||||
"-c",
|
||||
f"user:{self._token}",
|
||||
]
|
||||
if use_zsh:
|
||||
command.append("zsh")
|
||||
else:
|
||||
command.append("bash")
|
||||
|
||||
# Start the ttyd server
|
||||
self._server_process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
self._wait_for_server_port()
|
||||
|
||||
self._server_process.stdout.close()
|
||||
self._server_process.stderr.close()
|
||||
|
||||
def _wait_for_server_port(self, timeout: float = 10):
|
||||
"""
|
||||
Wait for the ttyd server to start and get the port number.
|
||||
|
||||
Args:
|
||||
timeout (float): The timeout in seconds to wait for the server to start.
|
||||
"""
|
||||
start_time = time.time()
|
||||
while True:
|
||||
output = self._server_process.stderr.readline()
|
||||
if output == b"" and self._server_process.poll() is not None:
|
||||
break
|
||||
if not output:
|
||||
continue
|
||||
|
||||
output = output.decode("utf-8").strip()
|
||||
if "Listening on" in output:
|
||||
# Extract the port number from the output
|
||||
self._server_port = int(output.split(":")[-1])
|
||||
logger.info(f"ttyd server started on port {self._server_port}")
|
||||
break
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(
|
||||
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the registry by removing any instances that are no longer valid.
|
||||
"""
|
||||
for gui_id, weak_ref in list(self._instances.items()):
|
||||
if weak_ref() is None:
|
||||
del self._instances[gui_id]
|
||||
|
||||
if not self._instances and self._server_process:
|
||||
# If no instances are left, terminate the server process
|
||||
self._server_process.terminate()
|
||||
self._server_process = None
|
||||
self._server_port = None
|
||||
logger.info("ttyd server terminated")
|
||||
|
||||
def unregister(self, instance: WebConsole):
|
||||
"""
|
||||
Unregister an instance of WebConsole.
|
||||
|
||||
Args:
|
||||
instance (WebConsole): The instance to unregister.
|
||||
"""
|
||||
if instance.gui_id in self._instances:
|
||||
del self._instances[instance.gui_id]
|
||||
|
||||
self.cleanup()
|
||||
|
||||
|
||||
_web_console_registry = WebConsoleRegistry()
|
||||
|
||||
|
||||
def suppress_qt_messages(type_, context, msg):
|
||||
if context.category in ["js", "default"]:
|
||||
return
|
||||
print(msg)
|
||||
|
||||
|
||||
qInstallMessageHandler(suppress_qt_messages)
|
||||
|
||||
|
||||
class BECWebEnginePage(QWebEnginePage):
|
||||
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
|
||||
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
|
||||
|
||||
|
||||
class WebConsole(BECWidget, QWidget):
|
||||
"""
|
||||
A simple widget to display a website
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.browser = QWebEngineView(self)
|
||||
self.page = BECWebEnginePage(self)
|
||||
self.page.authenticationRequired.connect(self._authenticate)
|
||||
self.browser.setPage(self.page)
|
||||
layout.addWidget(self.browser)
|
||||
self.setLayout(layout)
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
|
||||
def write(self, data: str, send_return: bool = True):
|
||||
"""
|
||||
Send data to the web page
|
||||
"""
|
||||
self.page.runJavaScript(f"window.term.paste('{data}');")
|
||||
if send_return:
|
||||
self.send_return()
|
||||
|
||||
def _authenticate(self, _, auth):
|
||||
"""
|
||||
Authenticate the request with the provided username and password.
|
||||
"""
|
||||
auth.setUser("user")
|
||||
auth.setPassword(self._token)
|
||||
|
||||
def send_return(self):
|
||||
"""
|
||||
Send return to the web page
|
||||
"""
|
||||
self.page.runJavaScript(
|
||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
|
||||
)
|
||||
|
||||
def send_ctrl_c(self):
|
||||
"""
|
||||
Send Ctrl+C to the web page
|
||||
"""
|
||||
self.page.runJavaScript(
|
||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the registry by removing any instances that are no longer valid.
|
||||
"""
|
||||
_web_console_registry.unregister(self)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = WebConsole()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['web_console.py']}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='WebConsole' name='web_console'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = WebConsole(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Console"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(WebConsole.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "web_console"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "WebConsole"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -8,18 +8,27 @@ from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import QPointF, Signal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.image.image_roi_plot import ImageROIPlot
|
||||
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
|
||||
MonitorSelectionToolbarBundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -111,8 +120,12 @@ class Image(PlotBase):
|
||||
"transpose.setter",
|
||||
"image",
|
||||
"main_image",
|
||||
"add_roi",
|
||||
"remove_roi",
|
||||
"rois",
|
||||
]
|
||||
sync_colorbar_with_autorange = Signal()
|
||||
image_updated = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -128,6 +141,9 @@ class Image(PlotBase):
|
||||
self.gui_id = config.gui_id
|
||||
self._color_bar = None
|
||||
self._main_image = ImageItem()
|
||||
self.roi_controller = ROIController(colormap="viridis")
|
||||
self.x_roi = None
|
||||
self.y_roi = None
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
@@ -139,22 +155,60 @@ class Image(PlotBase):
|
||||
# Default Color map to plasma
|
||||
self.color_map = "plasma"
|
||||
|
||||
# Initialize ROI plots and side panels
|
||||
self._add_roi_plots()
|
||||
|
||||
self.roi_manager_dialog = None
|
||||
|
||||
# Refresh theme for ROI plots
|
||||
self._update_theme()
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
def apply_theme(self, theme: str):
|
||||
super().apply_theme(theme)
|
||||
if self.x_roi is not None and self.y_roi is not None:
|
||||
self.x_roi.apply_theme(theme)
|
||||
self.y_roi.apply_theme(theme)
|
||||
|
||||
def _init_toolbar(self):
|
||||
|
||||
# add to the first position
|
||||
self.selection_bundle = MonitorSelectionToolbarBundle(
|
||||
bundle_id="selection", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.selection_bundle, self)
|
||||
self.toolbar.add_bundle(bundle=self.selection_bundle, target_widget=self)
|
||||
|
||||
super()._init_toolbar()
|
||||
|
||||
# Image specific changes to PlotBase toolbar
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
|
||||
# ROI Bundle replacement with switchable crosshair
|
||||
self.toolbar.remove_bundle("roi")
|
||||
crosshair = MaterialIconAction(
|
||||
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
|
||||
)
|
||||
crosshair_roi = MaterialIconAction(
|
||||
icon_name="my_location",
|
||||
tooltip="Show Crosshair with ROI plots",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
|
||||
crosshair.action.toggled.connect(self.toggle_crosshair)
|
||||
switch_crosshair = SwitchableToolBarAction(
|
||||
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
|
||||
initial_action="crosshair_simple",
|
||||
tooltip="Crosshair",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
self.toolbar.add_action(
|
||||
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
|
||||
)
|
||||
|
||||
# Lock aspect ratio button
|
||||
self.lock_aspect_ratio_action = MaterialIconAction(
|
||||
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
|
||||
@@ -203,11 +257,8 @@ class Image(PlotBase):
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="autorange_image",
|
||||
action=self.autorange_switch,
|
||||
target_widget=self,
|
||||
self.toolbar.add_action(
|
||||
action_id="autorange_image", action=self.autorange_switch, target_widget=self
|
||||
)
|
||||
|
||||
self.autorange_mean_action.action.toggled.connect(
|
||||
@@ -239,11 +290,8 @@ class Image(PlotBase):
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="switch_colorbar",
|
||||
action=self.colorbar_switch,
|
||||
target_widget=self,
|
||||
self.toolbar.add_action(
|
||||
action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
|
||||
)
|
||||
|
||||
self.simple_colorbar_action.action.toggled.connect(
|
||||
@@ -253,6 +301,55 @@ class Image(PlotBase):
|
||||
lambda checked: self.enable_colorbar(checked, style="full")
|
||||
)
|
||||
|
||||
########################################
|
||||
# ROI Gui Manager
|
||||
def add_side_menus(self):
|
||||
super().add_side_menus()
|
||||
|
||||
roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
||||
self.side_panel.add_menu(
|
||||
action_id="roi_mgr",
|
||||
icon_name="view_list",
|
||||
tooltip="ROI Manager",
|
||||
widget=roi_mgr,
|
||||
title="ROI Manager",
|
||||
)
|
||||
|
||||
def add_popups(self):
|
||||
super().add_popups() # keep Axis Settings
|
||||
|
||||
roi_action = MaterialIconAction(
|
||||
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
||||
)
|
||||
# self.popup_bundle.add_action("roi_mgr", roi_action)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
|
||||
)
|
||||
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
|
||||
|
||||
def show_roi_manager_popup(self):
|
||||
roi_action = self.toolbar.widgets["roi_mgr"].action
|
||||
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
|
||||
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
||||
self.roi_manager_dialog = QDialog(modal=False)
|
||||
self.roi_manager_dialog.layout = QVBoxLayout(self.roi_manager_dialog)
|
||||
self.roi_manager_dialog.layout.addWidget(self.roi_mgr)
|
||||
self.roi_manager_dialog.finished.connect(self._roi_mgr_closed)
|
||||
self.roi_manager_dialog.show()
|
||||
roi_action.setChecked(True)
|
||||
else:
|
||||
self.roi_manager_dialog.raise_()
|
||||
self.roi_manager_dialog.activateWindow()
|
||||
roi_action.setChecked(True)
|
||||
|
||||
def _roi_mgr_closed(self):
|
||||
self.roi_mgr.close()
|
||||
self.roi_mgr.deleteLater()
|
||||
self.roi_manager_dialog.close()
|
||||
self.roi_manager_dialog.deleteLater()
|
||||
self.roi_manager_dialog = None
|
||||
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
|
||||
|
||||
def enable_colorbar(
|
||||
self,
|
||||
enabled: bool,
|
||||
@@ -304,9 +401,177 @@ class Image(PlotBase):
|
||||
if vrange: # should be at the end to disable the autorange if defined
|
||||
self.v_range = vrange
|
||||
|
||||
################################################################################
|
||||
# Static rois with roi manager
|
||||
|
||||
def add_roi(
|
||||
self,
|
||||
kind: Literal["rect", "circle"] = "rect",
|
||||
name: str | None = None,
|
||||
line_width: int | None = 5,
|
||||
pos: tuple[float, float] | None = (10, 10),
|
||||
size: tuple[float, float] | None = (50, 50),
|
||||
**pg_kwargs,
|
||||
) -> RectangularROI | CircularROI:
|
||||
"""
|
||||
Add a ROI to the image.
|
||||
|
||||
Args:
|
||||
kind(str): The type of ROI to add. Options are "rect" or "circle".
|
||||
name(str): The name of the ROI.
|
||||
line_width(int): The line width of the ROI.
|
||||
pos(tuple): The position of the ROI.
|
||||
size(tuple): The size of the ROI.
|
||||
**pg_kwargs: Additional arguments for the ROI.
|
||||
|
||||
Returns:
|
||||
RectangularROI | CircularROI: The created ROI object.
|
||||
"""
|
||||
if name is None:
|
||||
name = f"ROI_{len(self.roi_controller.rois) + 1}"
|
||||
if kind == "rect":
|
||||
roi = RectangularROI(
|
||||
pos=pos,
|
||||
size=size,
|
||||
parent_image=self,
|
||||
line_width=line_width,
|
||||
label=name,
|
||||
**pg_kwargs,
|
||||
)
|
||||
elif kind == "circle":
|
||||
roi = CircularROI(
|
||||
pos=pos,
|
||||
size=size,
|
||||
parent_image=self,
|
||||
line_width=line_width,
|
||||
label=name,
|
||||
**pg_kwargs,
|
||||
)
|
||||
else:
|
||||
raise ValueError("kind must be 'rect' or 'circle'")
|
||||
|
||||
# Add to plot and controller (controller assigns color)
|
||||
self.plot_item.addItem(roi)
|
||||
self.roi_controller.add_roi(roi)
|
||||
roi.add_scale_handle()
|
||||
return roi
|
||||
|
||||
def remove_roi(self, roi: int | str):
|
||||
"""Remove an ROI by index or label via the ROIController."""
|
||||
if isinstance(roi, int):
|
||||
self.roi_controller.remove_roi_by_index(roi)
|
||||
elif isinstance(roi, str):
|
||||
self.roi_controller.remove_roi_by_name(roi)
|
||||
else:
|
||||
raise ValueError("roi must be an int index or str name")
|
||||
|
||||
def _add_roi_plots(self):
|
||||
"""
|
||||
Initialize the ROI plots and side panels.
|
||||
"""
|
||||
# Create ROI plot widgets
|
||||
self.x_roi = ImageROIPlot(parent=self)
|
||||
self.y_roi = ImageROIPlot(parent=self)
|
||||
self.x_roi.apply_theme("dark")
|
||||
self.y_roi.apply_theme("dark")
|
||||
|
||||
# Set titles for the plots
|
||||
self.x_roi.plot_item.setTitle("X ROI")
|
||||
self.y_roi.plot_item.setTitle("Y ROI")
|
||||
|
||||
# Create side panels
|
||||
self.side_panel_x = SidePanel(
|
||||
parent=self, orientation="bottom", panel_max_width=200, show_toolbar=False
|
||||
)
|
||||
self.side_panel_y = SidePanel(
|
||||
parent=self, orientation="left", panel_max_width=200, show_toolbar=False
|
||||
)
|
||||
|
||||
# Add ROI plots to side panels
|
||||
self.x_panel_index = self.side_panel_x.add_menu(widget=self.x_roi)
|
||||
self.y_panel_index = self.side_panel_y.add_menu(widget=self.y_roi)
|
||||
|
||||
# # Add side panels to the layout
|
||||
self.layout_manager.add_widget_relative(
|
||||
self.side_panel_x, self.round_plot_widget, position="bottom", shift_direction="down"
|
||||
)
|
||||
self.layout_manager.add_widget_relative(
|
||||
self.side_panel_y, self.round_plot_widget, position="left", shift_direction="right"
|
||||
)
|
||||
|
||||
def toggle_roi_panels(self, checked: bool):
|
||||
"""
|
||||
Show or hide the ROI panels based on the test action toggle state.
|
||||
|
||||
Args:
|
||||
checked (bool): Whether the test action is checked.
|
||||
"""
|
||||
if checked:
|
||||
# Show the ROI panels
|
||||
self.hook_crosshair()
|
||||
self.side_panel_x.show_panel(self.x_panel_index)
|
||||
self.side_panel_y.show_panel(self.y_panel_index)
|
||||
self.crosshair.coordinatesChanged2D.connect(self.update_image_slices)
|
||||
self.image_updated.connect(self.update_image_slices)
|
||||
else:
|
||||
self.unhook_crosshair()
|
||||
# Hide the ROI panels
|
||||
self.side_panel_x.hide_panel()
|
||||
self.side_panel_y.hide_panel()
|
||||
self.image_updated.disconnect(self.update_image_slices)
|
||||
|
||||
@SafeSlot()
|
||||
def update_image_slices(self, coordinates: tuple[int, int, int] = None):
|
||||
"""
|
||||
Update the image slices based on the crosshair position.
|
||||
|
||||
Args:
|
||||
coordinates(tuple): The coordinates of the crosshair.
|
||||
"""
|
||||
if coordinates is None:
|
||||
# Try to get coordinates from crosshair position (like in crosshair mouse_moved)
|
||||
if (
|
||||
hasattr(self, "crosshair")
|
||||
and hasattr(self.crosshair, "v_line")
|
||||
and hasattr(self.crosshair, "h_line")
|
||||
):
|
||||
x = int(round(self.crosshair.v_line.value()))
|
||||
y = int(round(self.crosshair.h_line.value()))
|
||||
else:
|
||||
return
|
||||
else:
|
||||
x = coordinates[1]
|
||||
y = coordinates[2]
|
||||
image = self._main_image.image
|
||||
if image is None:
|
||||
return
|
||||
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
|
||||
row, col = x, y
|
||||
if not (0 <= row <= max_row and 0 <= col <= max_col):
|
||||
return
|
||||
# Horizontal slice
|
||||
h_slice = image[:, col]
|
||||
x_axis = np.arange(h_slice.shape[0])
|
||||
self.x_roi.plot_item.clear()
|
||||
self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
|
||||
# Vertical slice
|
||||
v_slice = image[row, :]
|
||||
y_axis = np.arange(v_slice.shape[0])
|
||||
self.y_roi.plot_item.clear()
|
||||
self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
################################################################################
|
||||
################################################################################
|
||||
# Rois
|
||||
|
||||
@property
|
||||
def rois(self) -> list[BaseROI]:
|
||||
"""
|
||||
Get the list of ROIs.
|
||||
"""
|
||||
return self.roi_controller.rois
|
||||
|
||||
################################################################################
|
||||
# Colorbar toggle
|
||||
@@ -849,6 +1114,7 @@ class Image(PlotBase):
|
||||
self._main_image.set_data(image_buffer)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
self.image_updated.emit()
|
||||
|
||||
def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
@@ -900,6 +1166,7 @@ class Image(PlotBase):
|
||||
self._main_image.set_data(data)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
self.image_updated.emit()
|
||||
|
||||
################################################################################
|
||||
# Clean up
|
||||
@@ -925,6 +1192,11 @@ class Image(PlotBase):
|
||||
"""
|
||||
Disconnect the image update signals and clean up the image.
|
||||
"""
|
||||
# Remove all ROIs
|
||||
rois = self.rois
|
||||
for roi in rois:
|
||||
roi.remove()
|
||||
|
||||
# Main Image cleanup
|
||||
if self._main_image.config.monitor is not None:
|
||||
self.disconnect_monitor(self._main_image.config.monitor)
|
||||
@@ -941,20 +1213,38 @@ class Image(PlotBase):
|
||||
self._color_bar.deleteLater()
|
||||
self._color_bar = None
|
||||
|
||||
# Popup cleanup
|
||||
if self.roi_manager_dialog is not None:
|
||||
self.roi_manager_dialog.reject()
|
||||
self.roi_manager_dialog = None
|
||||
|
||||
# Toolbar cleanup
|
||||
self.toolbar.widgets["monitor"].widget.close()
|
||||
self.toolbar.widgets["monitor"].widget.deleteLater()
|
||||
|
||||
# ROI plots cleanup
|
||||
self.x_roi.cleanup_pyqtgraph()
|
||||
self.y_roi.cleanup_pyqtgraph()
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = Image(popups=True)
|
||||
widget.show()
|
||||
widget.resize(1000, 800)
|
||||
win = QWidget()
|
||||
win.setWindowTitle("Image Demo")
|
||||
ml = QHBoxLayout(win)
|
||||
|
||||
image_popup = Image(popups=True)
|
||||
image_side_panel = Image(popups=False)
|
||||
|
||||
ml.addWidget(image_popup)
|
||||
ml.addWidget(image_side_panel)
|
||||
|
||||
win.resize(1500, 800)
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -61,8 +61,8 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
"fft.setter",
|
||||
"log",
|
||||
"log.setter",
|
||||
"rotation",
|
||||
"rotation.setter",
|
||||
"num_rotation_90",
|
||||
"num_rotation_90.setter",
|
||||
"transpose",
|
||||
"transpose.setter",
|
||||
"get_data",
|
||||
|
||||
37
bec_widgets/widgets/plots/image/image_roi_plot.py
Normal file
37
bec_widgets/widgets/plots/image/image_roi_plot.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import pyqtgraph as pg
|
||||
|
||||
from bec_widgets.utils.round_frame import RoundedFrame
|
||||
from bec_widgets.widgets.plots.plot_base import BECViewBox
|
||||
|
||||
|
||||
class ImageROIPlot(RoundedFrame):
|
||||
"""
|
||||
A widget for displaying an image with a region of interest (ROI) overlay.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.content_widget = pg.GraphicsLayoutWidget(self)
|
||||
self.layout.addWidget(self.content_widget)
|
||||
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
|
||||
self.content_widget.addItem(self.plot_item)
|
||||
self.curve_color = "w"
|
||||
|
||||
self.apply_plot_widget_style()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
if theme == "dark":
|
||||
self.curve_color = "w"
|
||||
else:
|
||||
self.curve_color = "k"
|
||||
for curve in self.plot_item.curves:
|
||||
curve.setPen(pg.mkPen(self.curve_color, width=3))
|
||||
super().apply_theme(theme)
|
||||
|
||||
def cleanup_pyqtgraph(self):
|
||||
"""Cleanup pyqtgraph items."""
|
||||
self.plot_item.vb.menu.close()
|
||||
self.plot_item.vb.menu.deleteLater()
|
||||
self.plot_item.ctrlMenu.close()
|
||||
self.plot_item.ctrlMenu.deleteLater()
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QColorDialog,
|
||||
QHeaderView,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||
BaseROI,
|
||||
CircularROI,
|
||||
RectangularROI,
|
||||
ROIController,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
|
||||
ColorButtonNative,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
class ROIPropertyTree(BECWidget, QWidget):
|
||||
"""
|
||||
Two-column tree: [ROI] [Properties]
|
||||
|
||||
- Top-level: ROI name (editable) + color button.
|
||||
- Children: type, line-width (spin box), coordinates (auto-updating).
|
||||
|
||||
Args:
|
||||
image_widget (Image): The main Image widget that displays the ImageItem.
|
||||
Provides ``plot_item`` and owns an ROIController already.
|
||||
controller (ROIController, optional): Optionally pass an external controller.
|
||||
If None, the manager uses ``image_widget.roi_controller``.
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
"""
|
||||
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
|
||||
COL_ACTION, COL_ROI, COL_PROPS = range(3)
|
||||
DELETE_BUTTON_COLOR = "#CC181E"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
parent: QWidget = None,
|
||||
image_widget: Image,
|
||||
controller: ROIController | None = None,
|
||||
):
|
||||
|
||||
super().__init__(
|
||||
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
)
|
||||
|
||||
if controller is None:
|
||||
# Use the controller already belonging to the Image widget
|
||||
controller = getattr(image_widget, "roi_controller", None)
|
||||
if controller is None:
|
||||
controller = ROIController()
|
||||
image_widget.roi_controller = controller
|
||||
|
||||
self.image_widget = image_widget
|
||||
self.plot = image_widget.plot_item
|
||||
self.controller = controller
|
||||
self.roi_items: dict[BaseROI, QTreeWidgetItem] = {}
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self._init_toolbar()
|
||||
self._init_tree()
|
||||
|
||||
# connect controller
|
||||
self.controller.roiAdded.connect(self._on_roi_added)
|
||||
self.controller.roiRemoved.connect(self._on_roi_removed)
|
||||
self.controller.cleared.connect(self.tree.clear)
|
||||
|
||||
# initial load
|
||||
for r in self.controller.rois:
|
||||
self._on_roi_added(r)
|
||||
|
||||
self.tree.collapseAll()
|
||||
|
||||
# --------------------------------------------------------------------- UI
|
||||
def _init_toolbar(self):
|
||||
tb = ModularToolBar(self, self, orientation="horizontal")
|
||||
# --- ROI draw actions (toggleable) ---
|
||||
self.add_rect_action = MaterialIconAction("add_box", "Add Rect ROI", True, self)
|
||||
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
|
||||
tb.add_action("Add Rect ROI", self.add_rect_action, self)
|
||||
tb.add_action("Add Circle ROI", self.add_circle_action, self)
|
||||
|
||||
# Expand/Collapse toggle
|
||||
self.expand_toggle = MaterialIconAction(
|
||||
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
|
||||
)
|
||||
tb.add_action("Expand/Collapse", self.expand_toggle, self)
|
||||
|
||||
def _exp_toggled(on: bool):
|
||||
if on:
|
||||
# switched to expanded state
|
||||
self.tree.expandAll()
|
||||
new_icon = material_icon("unfold_less", size=(20, 20), convert_to_pixmap=False)
|
||||
else:
|
||||
# collapsed state
|
||||
self.tree.collapseAll()
|
||||
new_icon = material_icon("unfold_more", size=(20, 20), convert_to_pixmap=False)
|
||||
self.expand_toggle.action.setIcon(new_icon)
|
||||
|
||||
self.expand_toggle.action.toggled.connect(_exp_toggled)
|
||||
|
||||
self.expand_toggle.action.setChecked(False)
|
||||
# colormap widget
|
||||
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
|
||||
tb.addWidget(QWidget()) # spacer
|
||||
tb.addWidget(self.cmap)
|
||||
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
|
||||
self.layout.addWidget(tb)
|
||||
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
||||
|
||||
# ROI drawing state
|
||||
self._roi_draw_mode = None # 'rect' | 'circle' | None
|
||||
self._roi_start_pos = None # QPointF in image coords
|
||||
self._temp_roi = None # live ROI being resized while dragging
|
||||
|
||||
# toggle handlers
|
||||
self.add_rect_action.action.toggled.connect(
|
||||
lambda on: self._set_roi_draw_mode("rect" if on else None)
|
||||
)
|
||||
self.add_circle_action.action.toggled.connect(
|
||||
lambda on: self._set_roi_draw_mode("circle" if on else None)
|
||||
)
|
||||
# capture mouse events on the plot scene
|
||||
self.plot.scene().installEventFilter(self)
|
||||
|
||||
def _init_tree(self):
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setColumnCount(3)
|
||||
self.tree.setHeaderLabels(["Actions", "ROI", "Properties"])
|
||||
self.tree.header().setSectionResizeMode(self.COL_ACTION, QHeaderView.ResizeToContents)
|
||||
self.tree.headerItem().setText(self.COL_ACTION, "Actions") # blank header text
|
||||
self.tree.itemChanged.connect(self._on_item_edited)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
################################################################################
|
||||
# Helper functions
|
||||
################################################################################
|
||||
|
||||
# --------------------------------------------------------------------- formatting
|
||||
@staticmethod
|
||||
def _format_coord_text(value) -> str:
|
||||
"""
|
||||
Consistently format a coordinate value for display.
|
||||
"""
|
||||
if isinstance(value, (tuple, list)):
|
||||
return "(" + ", ".join(f"{v:.2f}" for v in value) + ")"
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}"
|
||||
return str(value)
|
||||
|
||||
def _set_roi_draw_mode(self, mode: str | None):
|
||||
# Ensure only the selected action is toggled on
|
||||
if mode == "rect":
|
||||
self.add_rect_action.action.setChecked(True)
|
||||
self.add_circle_action.action.setChecked(False)
|
||||
elif mode == "circle":
|
||||
self.add_rect_action.action.setChecked(False)
|
||||
self.add_circle_action.action.setChecked(True)
|
||||
else:
|
||||
self.add_rect_action.action.setChecked(False)
|
||||
self.add_circle_action.action.setChecked(False)
|
||||
self._roi_draw_mode = mode
|
||||
self._roi_start_pos = None
|
||||
# remove any unfinished temp ROI
|
||||
if self._temp_roi is not None:
|
||||
self.plot.removeItem(self._temp_roi)
|
||||
self._temp_roi = None
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if self._roi_draw_mode is None:
|
||||
return super().eventFilter(obj, event)
|
||||
if event.type() == QEvent.GraphicsSceneMousePress and event.button() == Qt.LeftButton:
|
||||
self._roi_start_pos = self.plot.vb.mapSceneToView(event.scenePos())
|
||||
if self._roi_draw_mode == "rect":
|
||||
self._temp_roi = RectangularROI(
|
||||
pos=[self._roi_start_pos.x(), self._roi_start_pos.y()],
|
||||
size=[5, 5],
|
||||
parent_image=self.image_widget,
|
||||
resize_handles=False,
|
||||
)
|
||||
if self._roi_draw_mode == "circle":
|
||||
self._temp_roi = CircularROI(
|
||||
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
|
||||
size=[5, 5],
|
||||
parent_image=self.image_widget,
|
||||
)
|
||||
self.plot.addItem(self._temp_roi)
|
||||
return True
|
||||
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
|
||||
pos = self.plot.vb.mapSceneToView(event.scenePos())
|
||||
dx = pos.x() - self._roi_start_pos.x()
|
||||
dy = pos.y() - self._roi_start_pos.y()
|
||||
|
||||
if self._roi_draw_mode == "rect":
|
||||
self._temp_roi.setSize([dx, dy])
|
||||
if self._roi_draw_mode == "circle":
|
||||
r = max(
|
||||
1, math.hypot(dx, dy)
|
||||
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
|
||||
d = 2 * r # diameter
|
||||
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
|
||||
self._temp_roi.setSize([d, d])
|
||||
return True
|
||||
elif (
|
||||
event.type() == QEvent.GraphicsSceneMouseRelease
|
||||
and event.button() == Qt.LeftButton
|
||||
and self._temp_roi is not None
|
||||
):
|
||||
# finalize ROI
|
||||
final_roi = self._temp_roi
|
||||
self._temp_roi = None
|
||||
self._set_roi_draw_mode(None)
|
||||
# register via controller
|
||||
final_roi.add_scale_handle()
|
||||
self.controller.add_roi(final_roi)
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# --------------------------------------------------------- controller slots
|
||||
def _on_roi_added(self, roi: BaseROI):
|
||||
# parent row with blank action column, name in ROI column
|
||||
parent = QTreeWidgetItem(self.tree, ["", "", ""])
|
||||
parent.setText(self.COL_ROI, roi.label)
|
||||
parent.setFlags(parent.flags() | Qt.ItemIsEditable)
|
||||
# --- delete button in actions column ---
|
||||
del_btn = QToolButton()
|
||||
delete_icon = material_icon(
|
||||
"delete",
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=False,
|
||||
color=self.DELETE_BUTTON_COLOR,
|
||||
)
|
||||
del_btn.setIcon(delete_icon)
|
||||
self.tree.setItemWidget(parent, self.COL_ACTION, del_btn)
|
||||
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
|
||||
# color button
|
||||
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
|
||||
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
|
||||
color_btn.clicked.connect(lambda: self._pick_color(roi, color_btn))
|
||||
|
||||
# child rows (3 columns: action, ROI, properties)
|
||||
QTreeWidgetItem(parent, ["", "Type", roi.__class__.__name__])
|
||||
width_item = QTreeWidgetItem(parent, ["", "Line width", ""])
|
||||
width_spin = QSpinBox()
|
||||
width_spin.setRange(1, 50)
|
||||
width_spin.setValue(roi.line_width)
|
||||
self.tree.setItemWidget(width_item, self.COL_PROPS, width_spin)
|
||||
width_spin.valueChanged.connect(lambda v, r=roi: setattr(r, "line_width", v))
|
||||
|
||||
# --- Step 2: Insert separate coordinate rows (one per value)
|
||||
coord_rows = {}
|
||||
coords = roi.get_coordinates(typed=True)
|
||||
|
||||
for key, value in coords.items():
|
||||
# Human-readable label: “center x” from “center_x”, etc.
|
||||
label = key.replace("_", " ").title()
|
||||
val_text = self._format_coord_text(value)
|
||||
row = QTreeWidgetItem(parent, ["", label, val_text])
|
||||
coord_rows[key] = row
|
||||
|
||||
# keep dict refs
|
||||
self.roi_items[roi] = parent
|
||||
|
||||
# --- Step 3: Update coordinates on ROI movement
|
||||
def _update_coords():
|
||||
c_dict = roi.get_coordinates(typed=True)
|
||||
for k, row in coord_rows.items():
|
||||
if k in c_dict:
|
||||
val = c_dict[k]
|
||||
row.setText(self.COL_PROPS, self._format_coord_text(val))
|
||||
|
||||
if isinstance(roi, RectangularROI):
|
||||
roi.edgesChanged.connect(_update_coords)
|
||||
else:
|
||||
roi.centerChanged.connect(_update_coords)
|
||||
|
||||
# sync width edits back to spinbox
|
||||
roi.penChanged.connect(lambda r=roi, sp=width_spin: sp.setValue(r.line_width))
|
||||
roi.nameChanged.connect(lambda n, itm=parent: itm.setText(self.COL_ROI, n))
|
||||
|
||||
# color changes
|
||||
roi.penChanged.connect(lambda r=roi, b=color_btn: b.set_color(r.line_color))
|
||||
|
||||
for c in range(3):
|
||||
self.tree.resizeColumnToContents(c)
|
||||
|
||||
def _on_roi_removed(self, roi: BaseROI):
|
||||
item = self.roi_items.pop(roi, None)
|
||||
if item:
|
||||
idx = self.tree.indexOfTopLevelItem(item)
|
||||
self.tree.takeTopLevelItem(idx)
|
||||
|
||||
# ---------------------------------------------------------- event handlers
|
||||
def _pick_color(self, roi: BaseROI, btn: "ColorButtonNative"):
|
||||
clr = QColorDialog.getColor(QColor(roi.line_color), self, "Select ROI Color")
|
||||
if clr.isValid():
|
||||
roi.line_color = clr.name()
|
||||
btn.set_color(clr)
|
||||
|
||||
def _on_item_edited(self, item: QTreeWidgetItem, col: int):
|
||||
if col != self.COL_ROI:
|
||||
return
|
||||
# find which roi
|
||||
for r, it in self.roi_items.items():
|
||||
if it is item:
|
||||
r.label = item.text(self.COL_ROI)
|
||||
break
|
||||
|
||||
def _delete_roi(self, roi):
|
||||
self.controller.remove_roi(roi)
|
||||
|
||||
def cleanup(self):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
# Demo
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
bec_dispatcher = BECDispatcher(gui_id="roi_tree_demo")
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
image_widget = Image(popups=False)
|
||||
image_widget.main_image.set_data(np.random.normal(size=(200, 200)))
|
||||
|
||||
win = QWidget()
|
||||
win.setWindowTitle("Modular ROI Demo")
|
||||
ml = QHBoxLayout(win)
|
||||
|
||||
# Add the image widget on the left
|
||||
ml.addWidget(image_widget)
|
||||
|
||||
# ROI manager linked to that image
|
||||
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
|
||||
mgr.setFixedWidth(350)
|
||||
ml.addWidget(mgr)
|
||||
|
||||
win.resize(1500, 600)
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -35,19 +35,20 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
|
||||
self.device_combo_box.addItem("", None)
|
||||
self.device_combo_box.setCurrentText("")
|
||||
self.device_combo_box.setToolTip("Select Device")
|
||||
self.device_combo_box.setFixedWidth(150)
|
||||
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
|
||||
|
||||
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
|
||||
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=False))
|
||||
|
||||
# 2) Dimension combo box
|
||||
self.dim_combo_box = QComboBox(parent=self.target_widget)
|
||||
self.dim_combo_box.addItems(["auto", "1d", "2d"])
|
||||
self.dim_combo_box.setCurrentText("auto")
|
||||
self.dim_combo_box.setToolTip("Monitor Dimension")
|
||||
self.dim_combo_box.setFixedWidth(60)
|
||||
self.dim_combo_box.setFixedWidth(100)
|
||||
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
|
||||
|
||||
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True))
|
||||
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=False))
|
||||
|
||||
# Connect slots, a device will be connected upon change of any combobox
|
||||
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
|
||||
|
||||
@@ -11,18 +11,31 @@ class ImageProcessingToolbarBundle(ToolbarBundle):
|
||||
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
|
||||
self.target_widget = target_widget
|
||||
|
||||
self.fft = MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True)
|
||||
self.log = MaterialIconAction(icon_name="log_scale", tooltip="Toggle Log", checkable=True)
|
||||
self.fft = MaterialIconAction(
|
||||
icon_name="fft", tooltip="Toggle FFT", checkable=True, parent=self.target_widget
|
||||
)
|
||||
self.log = MaterialIconAction(
|
||||
icon_name="log_scale", tooltip="Toggle Log", checkable=True, parent=self.target_widget
|
||||
)
|
||||
self.transpose = MaterialIconAction(
|
||||
icon_name="transform", tooltip="Transpose Image", checkable=True
|
||||
icon_name="transform",
|
||||
tooltip="Transpose Image",
|
||||
checkable=True,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.right = MaterialIconAction(
|
||||
icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
|
||||
icon_name="rotate_right",
|
||||
tooltip="Rotate image clockwise by 90 deg",
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.left = MaterialIconAction(
|
||||
icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
|
||||
icon_name="rotate_left",
|
||||
tooltip="Rotate image counterclockwise by 90 deg",
|
||||
parent=self.target_widget,
|
||||
)
|
||||
self.reset = MaterialIconAction(
|
||||
icon_name="reset_settings", tooltip="Reset Image Settings", parent=self.target_widget
|
||||
)
|
||||
self.reset = MaterialIconAction(icon_name="reset_settings", tooltip="Reset Image Settings")
|
||||
|
||||
self.add_action("fft", self.fft)
|
||||
self.add_action("log", self.log)
|
||||
|
||||
@@ -98,6 +98,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
|
||||
self.axis_settings_dialog = None
|
||||
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
|
||||
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
|
||||
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
|
||||
self.plot_widget.addItem(self.plot_item)
|
||||
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
|
||||
@@ -111,8 +112,10 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
|
||||
self._user_x_label = ""
|
||||
self._x_label_suffix = ""
|
||||
self._x_axis_units = ""
|
||||
self._user_y_label = ""
|
||||
self._y_label_suffix = ""
|
||||
self._y_axis_units = ""
|
||||
|
||||
# Plot Indicator Items
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
@@ -472,12 +475,31 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._x_label_suffix = suffix
|
||||
self._apply_x_label()
|
||||
|
||||
@property
|
||||
def x_label_units(self) -> str:
|
||||
"""
|
||||
The units of the x-axis.
|
||||
"""
|
||||
return self._x_axis_units
|
||||
|
||||
@x_label_units.setter
|
||||
def x_label_units(self, units: str):
|
||||
"""
|
||||
The units of the x-axis.
|
||||
|
||||
Args:
|
||||
units(str): The units to set.
|
||||
"""
|
||||
self._x_axis_units = units
|
||||
self._apply_x_label()
|
||||
|
||||
@property
|
||||
def x_label_combined(self) -> str:
|
||||
"""
|
||||
The final label shown on the axis = user portion + suffix.
|
||||
The final label shown on the axis = user portion + suffix + [units].
|
||||
"""
|
||||
return self._user_x_label + self._x_label_suffix
|
||||
units = f" [{self._x_axis_units}]" if self._x_axis_units else ""
|
||||
return self._user_x_label + self._x_label_suffix + units
|
||||
|
||||
def _apply_x_label(self):
|
||||
"""
|
||||
@@ -520,12 +542,31 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._y_label_suffix = suffix
|
||||
self._apply_y_label()
|
||||
|
||||
@property
|
||||
def y_label_units(self) -> str:
|
||||
"""
|
||||
The units of the y-axis.
|
||||
"""
|
||||
return self._y_axis_units
|
||||
|
||||
@y_label_units.setter
|
||||
def y_label_units(self, units: str):
|
||||
"""
|
||||
The units of the y-axis.
|
||||
|
||||
Args:
|
||||
units(str): The units to set.
|
||||
"""
|
||||
self._y_axis_units = units
|
||||
self._apply_y_label()
|
||||
|
||||
@property
|
||||
def y_label_combined(self) -> str:
|
||||
"""
|
||||
The final y label shown on the axis = user portion + suffix.
|
||||
The final y label shown on the axis = user portion + suffix + [units].
|
||||
"""
|
||||
return self._user_y_label + self._y_label_suffix
|
||||
units = f" [{self._y_axis_units}]" if self._y_axis_units else ""
|
||||
return self._user_y_label + self._y_label_suffix + units
|
||||
|
||||
def _apply_y_label(self):
|
||||
"""
|
||||
@@ -795,6 +836,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
"""
|
||||
self.plot_item.showAxis("top", value)
|
||||
self.plot_item.showAxis("right", value)
|
||||
|
||||
self.property_changed.emit("outer_axes", value)
|
||||
|
||||
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
|
||||
@@ -814,6 +856,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
"""
|
||||
self.plot_item.showAxis("bottom", value)
|
||||
self.plot_item.showAxis("left", value)
|
||||
|
||||
self._apply_x_label()
|
||||
self._apply_y_label()
|
||||
self.property_changed.emit("inner_axes", value)
|
||||
|
||||
0
bec_widgets/widgets/plots/roi/__init__.py
Normal file
0
bec_widgets/widgets/plots/roi/__init__.py
Normal file
894
bec_widgets/widgets/plots/roi/image_roi.py
Normal file
894
bec_widgets/widgets/plots/roi/image_roi.py
Normal file
@@ -0,0 +1,894 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import TextItem
|
||||
from pyqtgraph import functions as fn
|
||||
from pyqtgraph import mkPen
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
class LabelAdorner:
|
||||
"""Manages a TextItem label on top of any ROI, keeping it aligned."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
roi: BaseROI,
|
||||
anchor: tuple[int, int] = (0, 1),
|
||||
padding: int = 2,
|
||||
bg_color: str | tuple[int, int, int, int] = (0, 0, 0, 100),
|
||||
text_color: str | tuple[int, int, int, int] = "white",
|
||||
):
|
||||
"""
|
||||
Initializes a label overlay for a given region of interest (ROI), allowing for customization
|
||||
of text placement, padding, background color, and text color. Automatically attaches the label
|
||||
to the ROI and updates its position and content based on ROI changes.
|
||||
|
||||
Args:
|
||||
roi: The region of interest to which the label will be attached.
|
||||
anchor: Tuple specifying the label's anchor relative to the ROI. Default is (0, 1).
|
||||
padding: Integer specifying the padding around the label's text. Default is 2.
|
||||
bg_color: RGBA tuple for the label's background color. Default is (0, 0, 0, 100).
|
||||
text_color: String specifying the color of the label's text. Default is "white".
|
||||
"""
|
||||
self.roi = roi
|
||||
self.label = TextItem(anchor=anchor)
|
||||
self.padding = padding
|
||||
self.bg_rgba = bg_color
|
||||
self.text_color = text_color
|
||||
roi.addItem(self.label) if hasattr(roi, "addItem") else self.label.setParentItem(roi)
|
||||
# initial draw
|
||||
self._update_html(roi.label)
|
||||
self._reposition()
|
||||
# reconnect on geometry/name changes
|
||||
roi.sigRegionChanged.connect(self._reposition)
|
||||
if hasattr(roi, "nameChanged"):
|
||||
roi.nameChanged.connect(self._update_html)
|
||||
|
||||
def _update_html(self, text: str):
|
||||
"""
|
||||
Updates the HTML content of the label with the given text.
|
||||
|
||||
Creates an HTML div with the configured background color, text color, and padding,
|
||||
then sets this HTML as the content of the label.
|
||||
|
||||
Args:
|
||||
text (str): The text to display in the label.
|
||||
"""
|
||||
html = (
|
||||
f'<div style="background: rgba{self.bg_rgba}; '
|
||||
f"font-weight:bold; color:{self.text_color}; "
|
||||
f'padding:{self.padding}px;">{text}</div>'
|
||||
)
|
||||
self.label.setHtml(html)
|
||||
|
||||
def _reposition(self):
|
||||
"""
|
||||
Repositions the label to align with the ROI's current position.
|
||||
|
||||
This method is called whenever the ROI's position or size changes.
|
||||
It places the label at the bottom-left corner of the ROI's bounding rectangle.
|
||||
"""
|
||||
# put at top-left corner of ROI’s bounding rect
|
||||
size = self.roi.state["size"]
|
||||
height = size[1]
|
||||
self.label.setPos(0, height)
|
||||
|
||||
|
||||
class BaseROI(BECConnector):
|
||||
"""Base class for all Region of Interest (ROI) implementations.
|
||||
|
||||
This class serves as a mixin that provides common properties and methods for ROIs,
|
||||
including name, line color, and line width properties. It inherits from BECConnector
|
||||
to enable remote procedure call functionality.
|
||||
|
||||
Attributes:
|
||||
RPC (bool): Flag indicating if remote procedure calls are enabled.
|
||||
PLUGIN (bool): Flag indicating if this class is a plugin.
|
||||
nameChanged (Signal): Signal emitted when the ROI name changes.
|
||||
penChanged (Signal): Signal emitted when the ROI pen (color/width) changes.
|
||||
USER_ACCESS (list): List of methods and properties accessible via RPC.
|
||||
"""
|
||||
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
|
||||
nameChanged = Signal(str)
|
||||
penChanged = Signal()
|
||||
USER_ACCESS = [
|
||||
"label",
|
||||
"label.setter",
|
||||
"line_color",
|
||||
"line_color.setter",
|
||||
"line_width",
|
||||
"line_width.setter",
|
||||
"get_coordinates",
|
||||
"get_data_from_image",
|
||||
"set_position",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
# BECConnector kwargs
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None,
|
||||
# ROI-specific
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
# all remaining pg.*ROI kwargs (pos, size, pen, …)
|
||||
**pg_kwargs,
|
||||
):
|
||||
"""Base class for all modular ROIs.
|
||||
|
||||
Args:
|
||||
label (str): Human-readable name shown in ROI Manager and labels.
|
||||
line_color (str | None, optional): Initial pen color. Defaults to None.
|
||||
Controller may override color later.
|
||||
line_width (int, optional): Initial pen width. Defaults to 15.
|
||||
Controller may override width later.
|
||||
config (ConnectionConfig | None, optional): Standard BECConnector argument. Defaults to None.
|
||||
gui_id (str | None, optional): Standard BECConnector argument. Defaults to None.
|
||||
parent_image (BECConnector | None, optional): Standard BECConnector argument. Defaults to None.
|
||||
"""
|
||||
if config is None:
|
||||
config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
|
||||
self.set_parent(parent_image)
|
||||
self.parent_plot_item = parent_image.plot_item
|
||||
object_name = label.replace("-", "_").replace(" ", "_") if label else None
|
||||
super().__init__(
|
||||
object_name=object_name,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
removable=True,
|
||||
invertible=True,
|
||||
**pg_kwargs,
|
||||
)
|
||||
|
||||
self._label = label or "ROI"
|
||||
self._line_color = line_color or "#ffffff"
|
||||
self._line_width = line_width
|
||||
self._description = True
|
||||
self.setPen(mkPen(self._line_color, width=self._line_width))
|
||||
|
||||
def set_parent(self, parent: Image):
|
||||
"""
|
||||
Sets the parent image for this ROI.
|
||||
|
||||
Args:
|
||||
parent (Image): The parent image object to associate with this ROI.
|
||||
"""
|
||||
self.parent_image = parent
|
||||
|
||||
def parent(self):
|
||||
"""
|
||||
Gets the parent image associated with this ROI.
|
||||
|
||||
Returns:
|
||||
Image: The parent image object, or None if no parent is set.
|
||||
"""
|
||||
return self.parent_image
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""
|
||||
Gets the display name of this ROI.
|
||||
|
||||
Returns:
|
||||
str: The current name of the ROI.
|
||||
"""
|
||||
return self._label
|
||||
|
||||
@label.setter
|
||||
def label(self, new: str):
|
||||
"""
|
||||
Sets the display name of this ROI.
|
||||
|
||||
If the new name is different from the current name, this method updates
|
||||
the internal name, emits the nameChanged signal, and updates the object name.
|
||||
|
||||
Args:
|
||||
new (str): The new name to set for the ROI.
|
||||
"""
|
||||
if new != self._label:
|
||||
self._label = new
|
||||
self.nameChanged.emit(new)
|
||||
self.change_object_name(new)
|
||||
|
||||
@property
|
||||
def line_color(self) -> str:
|
||||
"""
|
||||
Gets the current line color of the ROI.
|
||||
|
||||
Returns:
|
||||
str: The current line color as a string (e.g., hex color code).
|
||||
"""
|
||||
return self._line_color
|
||||
|
||||
@line_color.setter
|
||||
def line_color(self, value: str):
|
||||
"""
|
||||
Sets the line color of the ROI.
|
||||
|
||||
If the new color is different from the current color, this method updates
|
||||
the internal color value, updates the pen while preserving the line width,
|
||||
and emits the penChanged signal.
|
||||
|
||||
Args:
|
||||
value (str): The new color to set for the ROI's outline (e.g., hex color code).
|
||||
"""
|
||||
if value != self._line_color:
|
||||
self._line_color = value
|
||||
# update pen but preserve width
|
||||
self.setPen(mkPen(value, width=self._line_width))
|
||||
self.penChanged.emit()
|
||||
|
||||
@property
|
||||
def line_width(self) -> int:
|
||||
"""
|
||||
Gets the current line width of the ROI.
|
||||
|
||||
Returns:
|
||||
int: The current line width in pixels.
|
||||
"""
|
||||
return self._line_width
|
||||
|
||||
@line_width.setter
|
||||
def line_width(self, value: int):
|
||||
"""
|
||||
Sets the line width of the ROI.
|
||||
|
||||
If the new width is different from the current width and is greater than 0,
|
||||
this method updates the internal width value, updates the pen while preserving
|
||||
the line color, and emits the penChanged signal.
|
||||
|
||||
Args:
|
||||
value (int): The new width to set for the ROI's outline in pixels.
|
||||
Must be greater than 0.
|
||||
"""
|
||||
if value != self._line_width and value > 0:
|
||||
self._line_width = value
|
||||
self.setPen(mkPen(self._line_color, width=value))
|
||||
self.penChanged.emit()
|
||||
|
||||
@property
|
||||
def description(self) -> bool:
|
||||
"""
|
||||
Gets whether ROI coordinates should be emitted with descriptive keys by default.
|
||||
|
||||
Returns:
|
||||
bool: True if coordinates should include descriptive keys, False otherwise.
|
||||
"""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: bool):
|
||||
"""
|
||||
Sets whether ROI coordinates should be emitted with descriptive keys by default.
|
||||
|
||||
This affects the default behavior of the get_coordinates method.
|
||||
|
||||
Args:
|
||||
value (bool): True to emit coordinates with descriptive keys, False to emit
|
||||
as a simple tuple of values.
|
||||
"""
|
||||
self._description = value
|
||||
|
||||
def get_coordinates(self):
|
||||
"""
|
||||
Gets the coordinates that define this ROI's position and shape.
|
||||
|
||||
This is an abstract method that must be implemented by subclasses.
|
||||
Implementations should return either a dictionary with descriptive keys
|
||||
or a tuple of coordinates, depending on the value of self.description.
|
||||
|
||||
Returns:
|
||||
dict or tuple: The coordinates defining the ROI's position and shape.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: This method must be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_coordinates()")
|
||||
|
||||
def get_data_from_image(
|
||||
self, image_item: pg.ImageItem | None = None, returnMappedCoords: bool = False, **kwargs
|
||||
):
|
||||
"""Wrapper around `pyqtgraph.ROI.getArrayRegion`.
|
||||
|
||||
Args:
|
||||
image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects
|
||||
the first `ImageItem` in the same GraphicsScene as this ROI.
|
||||
returnMappedCoords (bool): If True, also returns the coordinate array generated by
|
||||
*getArrayRegion*.
|
||||
**kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*,
|
||||
such as `axes`, `order`, `shape`, etc.
|
||||
|
||||
Returns:
|
||||
ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True.
|
||||
"""
|
||||
if image_item is None:
|
||||
image_item = next(
|
||||
(
|
||||
it
|
||||
for it in self.scene().items()
|
||||
if isinstance(it, pg.ImageItem) and it.image is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if image_item is None:
|
||||
raise RuntimeError("No ImageItem found in the current scene.")
|
||||
|
||||
data = image_item.image # the raw ndarray held by ImageItem
|
||||
return self.getArrayRegion(
|
||||
data, img=image_item, returnMappedCoords=returnMappedCoords, **kwargs
|
||||
)
|
||||
|
||||
def add_scale_handle(self):
|
||||
return
|
||||
|
||||
def set_position(self, x: float, y: float):
|
||||
"""
|
||||
Sets the position of the ROI.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate of the new position.
|
||||
y (float): The y-coordinate of the new position.
|
||||
"""
|
||||
self.setPos(x, y)
|
||||
|
||||
def remove(self):
|
||||
# Delegate to controller first so that GUI managers stay in sync
|
||||
controller = getattr(self.parent_image, "roi_controller", None)
|
||||
if controller and self in controller.rois:
|
||||
controller.remove_roi(self)
|
||||
return # controller will call back into this method once deregistered
|
||||
handles = self.handles
|
||||
for i in range(len(handles)):
|
||||
try:
|
||||
self.removeHandle(0)
|
||||
except IndexError:
|
||||
continue
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.parent_image.plot_item.removeItem(self)
|
||||
viewBox = self.parent_plot_item.vb
|
||||
viewBox.update()
|
||||
|
||||
|
||||
class RectangularROI(BaseROI, pg.RectROI):
|
||||
"""
|
||||
Defines a rectangular Region of Interest (ROI) with additional functionality.
|
||||
|
||||
Provides tools for manipulating and extracting data from rectangular areas on
|
||||
images, includes support for GUI features and event-driven signaling.
|
||||
|
||||
Attributes:
|
||||
edgesChanged (Signal): Signal emitted when the ROI edges change, providing
|
||||
the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
|
||||
edgesReleased (Signal): Signal emitted when the ROI edges are released,
|
||||
providing the new ("top_left", "top_right", "bottom_left","bottom_right") coordinates.
|
||||
"""
|
||||
|
||||
edgesChanged = Signal(float, float, float, float)
|
||||
edgesReleased = Signal(float, float, float, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
# pg.RectROI kwargs
|
||||
pos: tuple[float, float],
|
||||
size: tuple[float, float],
|
||||
pen=None,
|
||||
# BECConnector kwargs
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None = None,
|
||||
# ROI specifics
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
resize_handles: bool = True,
|
||||
**extra_pg,
|
||||
):
|
||||
"""
|
||||
Initializes an instance with properties for defining a rectangular ROI with handles,
|
||||
configurations, and an auto-aligning label. Also connects a signal for region updates.
|
||||
|
||||
Args:
|
||||
pos: Initial position of the ROI.
|
||||
size: Initial size of the ROI.
|
||||
pen: Defines the border appearance; can be color or style.
|
||||
config: Optional configuration details for the connection.
|
||||
gui_id: Optional identifier for the associated GUI element.
|
||||
parent_image: Optional parent object the ROI is related to.
|
||||
label: Optional label for identification within the context.
|
||||
line_color: Optional color of the ROI outline.
|
||||
line_width: Width of the ROI's outline in pixels.
|
||||
parent_plot_item: The plot item this ROI belongs to.
|
||||
**extra_pg: Additional keyword arguments specific to pg.RectROI.
|
||||
"""
|
||||
super().__init__(
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_image=parent_image,
|
||||
label=label,
|
||||
line_color=line_color,
|
||||
line_width=line_width,
|
||||
pos=pos,
|
||||
size=size,
|
||||
pen=pen,
|
||||
**extra_pg,
|
||||
)
|
||||
|
||||
self.sigRegionChanged.connect(self._on_region_changed)
|
||||
self.adorner = LabelAdorner(roi=self)
|
||||
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
|
||||
self.handleHoverPen = fn.mkPen("lime", width=4)
|
||||
|
||||
def add_scale_handle(self):
|
||||
"""
|
||||
Add scale handles at every corner and edge of the ROI.
|
||||
|
||||
Corner handles are anchored to the centre for two-axis scaling.
|
||||
Edge handles are anchored to the midpoint of the opposite edge for single-axis scaling.
|
||||
"""
|
||||
centre = [0.5, 0.5]
|
||||
|
||||
# Corner handles – anchored to the centre for two-axis scaling
|
||||
self.addScaleHandle([0, 0], centre) # top‑left
|
||||
self.addScaleHandle([1, 0], centre) # top‑right
|
||||
self.addScaleHandle([0, 1], centre) # bottom‑left
|
||||
self.addScaleHandle([1, 1], centre) # bottom‑right
|
||||
|
||||
# Edge handles – anchored to the midpoint of the opposite edge
|
||||
self.addScaleHandle([0.5, 0], [0.5, 1]) # top edge
|
||||
self.addScaleHandle([0.5, 1], [0.5, 0]) # bottom edge
|
||||
self.addScaleHandle([0, 0.5], [1, 0.5]) # left edge
|
||||
self.addScaleHandle([1, 0.5], [0, 0.5]) # right edge
|
||||
|
||||
self.handlePen = fn.mkPen("#ffff00", width=5) # bright yellow outline
|
||||
self.handleHoverPen = fn.mkPen("#00ffff", width=4) # cyan, thicker when hovered
|
||||
self.handleBrush = (200, 200, 0, 120) # semi-transparent fill
|
||||
self.handleHoverBrush = (0, 255, 255, 160)
|
||||
|
||||
def _on_region_changed(self):
|
||||
"""
|
||||
Handles ROI region change events.
|
||||
|
||||
This method is called whenever the ROI's position or size changes.
|
||||
It calculates the new corner coordinates and emits the edgesChanged signal
|
||||
with the updated coordinates.
|
||||
"""
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
self.edgesChanged.emit(x0, y0, x0 + w, y0 + h)
|
||||
viewBox = self.parent_plot_item.vb
|
||||
viewBox.update()
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""
|
||||
Handles mouse drag events on the ROI.
|
||||
|
||||
This method extends the parent class implementation to emit the edgesReleased
|
||||
signal when the mouse drag is finished, providing the final coordinates of the ROI.
|
||||
|
||||
Args:
|
||||
ev: The mouse event object containing information about the drag operation.
|
||||
"""
|
||||
super().mouseDragEvent(ev)
|
||||
if ev.isFinish():
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
self.edgesReleased.emit(x0, y0, x0 + w, y0 + h)
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
descriptive keys. If False, returns them as a tuple. Defaults to
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
x0, y0 = self.pos().x(), self.pos().y()
|
||||
w, h = self.state["size"]
|
||||
x1, y1 = x0 + w, y0 + h
|
||||
if typed:
|
||||
return {
|
||||
"bottom_left": (x0, y0),
|
||||
"bottom_right": (x1, y0),
|
||||
"top_left": (x0, y1),
|
||||
"top_right": (x1, y1),
|
||||
}
|
||||
return ((x0, y0), (x1, y0), (x0, y1), (x1, y1))
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
Searches for an image in the current scene.
|
||||
|
||||
This helper method iterates through all items in the scene and returns
|
||||
the first pg.ImageItem that has a non-None image property.
|
||||
|
||||
Returns:
|
||||
numpy.ndarray or None: The image from the first found ImageItem,
|
||||
or None if no suitable image is found.
|
||||
"""
|
||||
for it in self.scene().items():
|
||||
if isinstance(it, pg.ImageItem) and it.image is not None:
|
||||
return it.image
|
||||
return None
|
||||
|
||||
|
||||
class CircularROI(BaseROI, pg.CircleROI):
|
||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling.
|
||||
|
||||
This class extends the BaseROI and pg.CircleROI classes to provide a circular ROI
|
||||
that emits signals when its center or diameter changes, and includes an auto-aligning
|
||||
label for visual identification.
|
||||
|
||||
Attributes:
|
||||
centerChanged (Signal): Signal emitted when the ROI center or diameter changes,
|
||||
providing the new (center_x, center_y, diameter) values.
|
||||
centerReleased (Signal): Signal emitted when the ROI is released after dragging,
|
||||
providing the final (center_x, center_y, diameter) values.
|
||||
"""
|
||||
|
||||
centerChanged = Signal(float, float, float)
|
||||
centerReleased = Signal(float, float, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pos,
|
||||
size,
|
||||
pen=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
parent_image: Image | None = None,
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 5,
|
||||
**extra_pg,
|
||||
):
|
||||
"""
|
||||
Initializes a circular ROI with the specified properties.
|
||||
|
||||
Creates a circular ROI at the given position and with the given size,
|
||||
connects signals for tracking changes, and attaches an auto-aligning label.
|
||||
|
||||
Args:
|
||||
pos: Initial position of the ROI as [x, y].
|
||||
size: Initial size of the ROI as [diameter, diameter].
|
||||
pen: Defines the border appearance; can be color or style.
|
||||
config (ConnectionConfig | None, optional): Configuration for BECConnector. Defaults to None.
|
||||
gui_id (str | None, optional): Identifier for the GUI element. Defaults to None.
|
||||
parent_image (BECConnector | None, optional): Parent image object. Defaults to None.
|
||||
label (str | None, optional): Display name for the ROI. Defaults to None.
|
||||
line_color (str | None, optional): Color of the ROI outline. Defaults to None.
|
||||
line_width (int, optional): Width of the ROI outline in pixels. Defaults to 3.
|
||||
parent_plot_item: The plot item this ROI belongs to.
|
||||
**extra_pg: Additional keyword arguments for pg.CircleROI.
|
||||
"""
|
||||
super().__init__(
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_image=parent_image,
|
||||
label=label,
|
||||
line_color=line_color,
|
||||
line_width=line_width,
|
||||
pos=pos,
|
||||
size=size,
|
||||
pen=pen,
|
||||
**extra_pg,
|
||||
)
|
||||
self.sigRegionChanged.connect(self._on_region_changed)
|
||||
self._adorner = LabelAdorner(self)
|
||||
|
||||
def _on_region_changed(self):
|
||||
"""
|
||||
Handles ROI region change events.
|
||||
|
||||
This method is called whenever the ROI's position or size changes.
|
||||
It calculates the center coordinates and diameter of the circle and
|
||||
emits the centerChanged signal with these values.
|
||||
"""
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
self.centerChanged.emit(cx, cy, d)
|
||||
viewBox = self.parent_plot_item.getViewBox()
|
||||
viewBox.update()
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""
|
||||
Handles mouse drag events on the ROI.
|
||||
|
||||
This method extends the parent class implementation to emit the centerReleased
|
||||
signal when the mouse drag is finished, providing the final center coordinates
|
||||
and diameter of the circular ROI.
|
||||
|
||||
Args:
|
||||
ev: The mouse event object containing information about the drag operation.
|
||||
"""
|
||||
super().mouseDragEvent(ev)
|
||||
if ev.isFinish():
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
self.centerReleased.emit(cx, cy, d)
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Calculates and returns the coordinates and size of an object, either as a
|
||||
typed dictionary or as a tuple.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary. Defaults
|
||||
to None, which utilizes the object's description value.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys 'center_x', 'center_y', 'diameter', and 'radius'
|
||||
if `typed` is True.
|
||||
tuple: A tuple containing (center_x, center_y, diameter, radius) if `typed` is False.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
d = self.state["size"][0]
|
||||
cx = self.pos().x() + d / 2
|
||||
cy = self.pos().y() + d / 2
|
||||
|
||||
if typed:
|
||||
return {"center_x": cx, "center_y": cy, "diameter": d, "radius": d / 2}
|
||||
return (cx, cy, d, d / 2)
|
||||
|
||||
def _lookup_scene_image(self) -> pg.ImageItem | None:
|
||||
"""
|
||||
Retrieves an image from the scene items if available.
|
||||
|
||||
Iterates over all items in the scene and checks if any of them are of type
|
||||
`pg.ImageItem` and have a non-None image. If such an item is found, its image
|
||||
is returned.
|
||||
|
||||
Returns:
|
||||
pg.ImageItem or None: The first found ImageItem with a non-None image,
|
||||
or None if no suitable image is found.
|
||||
"""
|
||||
for it in self.scene().items():
|
||||
if isinstance(it, pg.ImageItem) and it.image is not None:
|
||||
return it.image
|
||||
return None
|
||||
|
||||
|
||||
class ROIController(QObject):
|
||||
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
|
||||
|
||||
Handles creating, adding, removing, and managing ROI instances. Supports color assignment
|
||||
from a colormap, and provides utility methods to access and manipulate ROIs.
|
||||
|
||||
Attributes:
|
||||
roiAdded (Signal): Emits the new ROI instance when added.
|
||||
roiRemoved (Signal): Emits the removed ROI instance when deleted.
|
||||
cleared (Signal): Emits when all ROIs are removed.
|
||||
paletteChanged (Signal): Emits the new colormap name when updated.
|
||||
_colormap (str): Name of the colormap used for ROI colors.
|
||||
_rois (list[BaseROI]): Internal list storing currently managed ROIs.
|
||||
_colors (list[str]): Internal list of colors for the ROIs.
|
||||
"""
|
||||
|
||||
roiAdded = Signal(object) # emits the new ROI instance
|
||||
roiRemoved = Signal(object) # emits the removed ROI instance
|
||||
cleared = Signal() # emits when all ROIs are removed
|
||||
paletteChanged = Signal(str) # emits new colormap name
|
||||
|
||||
def __init__(self, colormap="viridis"):
|
||||
"""
|
||||
Initializes the ROI controller with the specified colormap.
|
||||
|
||||
Sets up internal data structures for managing ROIs and their colors.
|
||||
|
||||
Args:
|
||||
colormap (str, optional): The name of the colormap to use for ROI colors.
|
||||
Defaults to "viridis".
|
||||
"""
|
||||
super().__init__()
|
||||
self._colormap = colormap
|
||||
self._rois: list[BaseROI] = []
|
||||
self._colors: list[str] = []
|
||||
self._rebuild_color_buffer()
|
||||
|
||||
def _rebuild_color_buffer(self):
|
||||
"""
|
||||
Regenerates the color buffer for ROIs.
|
||||
|
||||
This internal method creates a new list of colors based on the current colormap
|
||||
and the number of ROIs. It ensures there's always one more color than the number
|
||||
of ROIs to allow for adding a new ROI without regenerating the colors.
|
||||
"""
|
||||
n = len(self._rois) + 1
|
||||
self._colors = Colors.golden_angle_color(colormap=self._colormap, num=n, format="HEX")
|
||||
|
||||
def add_roi(self, roi: BaseROI):
|
||||
"""
|
||||
Registers an externally created ROI with this controller.
|
||||
|
||||
Adds the ROI to the internal list, assigns it a color from the color buffer,
|
||||
ensures it has an appropriate line width, and emits the roiAdded signal.
|
||||
|
||||
Args:
|
||||
roi (BaseROI): The ROI instance to register. Can be any subclass of BaseROI,
|
||||
such as RectangularROI or CircularROI.
|
||||
"""
|
||||
self._rois.append(roi)
|
||||
self._rebuild_color_buffer()
|
||||
idx = len(self._rois) - 1
|
||||
if roi.label == "ROI" or roi.label.startswith("ROI "):
|
||||
roi.label = f"ROI {idx}"
|
||||
color = self._colors[idx]
|
||||
roi.line_color = color
|
||||
# ensure line width default is at least 3 if not previously set
|
||||
if getattr(roi, "line_width", 0) < 1:
|
||||
roi.line_width = 5
|
||||
self.roiAdded.emit(roi)
|
||||
|
||||
def remove_roi(self, roi: BaseROI):
|
||||
"""
|
||||
Removes an ROI from this controller.
|
||||
|
||||
If the ROI is found in the internal list, it is removed, the color buffer
|
||||
is regenerated, and the roiRemoved signal is emitted.
|
||||
|
||||
Args:
|
||||
roi (BaseROI): The ROI instance to remove.
|
||||
"""
|
||||
if roi in self._rois:
|
||||
self.roiRemoved.emit(roi)
|
||||
self._rois.remove(roi)
|
||||
roi.remove()
|
||||
self._rebuild_color_buffer()
|
||||
else:
|
||||
roi.remove()
|
||||
|
||||
def get_roi(self, index: int) -> BaseROI | None:
|
||||
"""
|
||||
Returns the ROI at the specified index.
|
||||
|
||||
Args:
|
||||
index (int): The index of the ROI to retrieve.
|
||||
|
||||
Returns:
|
||||
BaseROI or None: The ROI at the specified index, or None if the index
|
||||
is out of range.
|
||||
"""
|
||||
if 0 <= index < len(self._rois):
|
||||
return self._rois[index]
|
||||
return None
|
||||
|
||||
def get_roi_by_name(self, name: str) -> BaseROI | None:
|
||||
"""
|
||||
Returns the first ROI with the specified name.
|
||||
|
||||
Args:
|
||||
name (str): The name to search for (case-sensitive).
|
||||
|
||||
Returns:
|
||||
BaseROI or None: The first ROI with a matching name, or None if no
|
||||
matching ROI is found.
|
||||
"""
|
||||
for r in self._rois:
|
||||
if r.label == name:
|
||||
return r
|
||||
return None
|
||||
|
||||
def remove_roi_by_index(self, index: int):
|
||||
"""
|
||||
Removes the ROI at the specified index.
|
||||
|
||||
Args:
|
||||
index (int): The index of the ROI to remove.
|
||||
"""
|
||||
roi = self.get_roi(index)
|
||||
if roi is not None:
|
||||
self.remove_roi(roi)
|
||||
|
||||
def remove_roi_by_name(self, name: str):
|
||||
"""
|
||||
Removes the first ROI with the specified name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the ROI to remove (case-sensitive).
|
||||
"""
|
||||
roi = self.get_roi_by_name(name)
|
||||
if roi is not None:
|
||||
self.remove_roi(roi)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Removes all ROIs from this controller.
|
||||
|
||||
Iterates through all ROIs and removes them one by one, then emits
|
||||
the cleared signal to notify listeners that all ROIs have been removed.
|
||||
"""
|
||||
for roi in list(self._rois):
|
||||
self.remove_roi(roi)
|
||||
self.cleared.emit()
|
||||
|
||||
def renormalize_colors(self):
|
||||
"""
|
||||
Reassigns palette colors to all ROIs in order.
|
||||
|
||||
Regenerates the color buffer based on the current colormap and number of ROIs,
|
||||
then assigns each ROI a color from the buffer in the order they were added.
|
||||
This is useful after changing the colormap or when ROIs need to be visually
|
||||
distinguished from each other.
|
||||
"""
|
||||
self._rebuild_color_buffer()
|
||||
for idx, roi in enumerate(self._rois):
|
||||
roi.line_color = self._colors[idx]
|
||||
|
||||
@SafeProperty(str)
|
||||
def colormap(self):
|
||||
"""
|
||||
Gets the name of the colormap used for ROI colors.
|
||||
|
||||
Returns:
|
||||
str: The name of the colormap.
|
||||
"""
|
||||
return self._colormap
|
||||
|
||||
@colormap.setter
|
||||
def colormap(self, cmap: str):
|
||||
"""
|
||||
Sets the colormap used for ROI colors.
|
||||
|
||||
Updates the internal colormap name and reassigns colors to all ROIs
|
||||
based on the new colormap.
|
||||
|
||||
Args:
|
||||
cmap (str): The name of the colormap to use (e.g., "viridis", "plasma").
|
||||
"""
|
||||
|
||||
self.set_colormap(cmap)
|
||||
|
||||
def set_colormap(self, cmap: str):
|
||||
Colors.validate_color_map(cmap)
|
||||
self._colormap = cmap
|
||||
self.paletteChanged.emit(cmap)
|
||||
self.renormalize_colors()
|
||||
|
||||
@property
|
||||
def rois(self) -> list[BaseROI]:
|
||||
"""
|
||||
Gets a copy of the list of ROIs managed by this controller.
|
||||
|
||||
Returns a new list containing all the ROIs currently managed by this controller.
|
||||
The list is a copy, so modifying it won't affect the controller's internal list.
|
||||
|
||||
Returns:
|
||||
list[BaseROI]: A list of all ROIs currently managed by this controller.
|
||||
"""
|
||||
return list(self._rois)
|
||||
|
||||
def cleanup(self):
|
||||
for roi in self._rois:
|
||||
self.remove_roi(roi)
|
||||
@@ -44,7 +44,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
|
||||
initial_action="drag_mode",
|
||||
tooltip="Mouse Modes",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
parent=self.target_widget,
|
||||
)
|
||||
|
||||
# Add them to the bundle
|
||||
|
||||
@@ -31,6 +31,9 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
|
||||
)
|
||||
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
|
||||
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
|
||||
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
|
||||
ColorButtonNative,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -40,49 +43,6 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
"""A QPushButton subclass that displays a color.
|
||||
|
||||
The background is set to the given color and the button text is the hex code.
|
||||
The text color is chosen automatically (black if the background is light, white if dark)
|
||||
to guarantee good readability.
|
||||
"""
|
||||
|
||||
def __init__(self, color="#000000", parent=None):
|
||||
"""Initialize the color button.
|
||||
|
||||
Args:
|
||||
color (str): The initial color in hex format (e.g., '#000000').
|
||||
parent: Optional QWidget parent.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.set_color(color)
|
||||
|
||||
def set_color(self, color):
|
||||
"""Set the button's color and update its appearance.
|
||||
|
||||
Args:
|
||||
color (str or QColor): The new color to assign.
|
||||
"""
|
||||
if isinstance(color, QColor):
|
||||
self._color = color.name()
|
||||
else:
|
||||
self._color = color
|
||||
self._update_appearance()
|
||||
|
||||
def color(self):
|
||||
"""Return the current color in hex."""
|
||||
return self._color
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the button style based on the background color's brightness."""
|
||||
c = QColor(self._color)
|
||||
brightness = c.lightnessF()
|
||||
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
|
||||
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
|
||||
self.setText(self._color)
|
||||
|
||||
|
||||
class CurveRow(QTreeWidgetItem):
|
||||
DELETE_BUTTON_COLOR = "#CC181E"
|
||||
"""A unified row that can represent either a device or a DAP curve.
|
||||
@@ -193,7 +153,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
def _init_style_controls(self):
|
||||
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 3
|
||||
self.color_button = ColorButton(self.config.color)
|
||||
self.color_button = ColorButtonNative(color=self.config.color)
|
||||
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
|
||||
@@ -284,6 +244,11 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.dap_combo.deleteLater()
|
||||
self.dap_combo = None
|
||||
|
||||
if getattr(self, "color_button", None) is not None:
|
||||
self.color_button.close()
|
||||
self.color_button.deleteLater()
|
||||
self.color_button = None
|
||||
|
||||
# Remove the item from the tree widget
|
||||
index = self.tree.indexOfTopLevelItem(self)
|
||||
if index != -1:
|
||||
@@ -337,8 +302,8 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.config.label = f"{parent_conf.label}-{new_dap}"
|
||||
|
||||
# Common style fields
|
||||
self.config.color = self.color_button.color()
|
||||
self.config.symbol_color = self.color_button.color()
|
||||
self.config.color = self.color_button.color
|
||||
self.config.symbol_color = self.color_button.color
|
||||
self.config.pen_style = self.style_combo.currentText()
|
||||
self.config.pen_width = self.width_spin.value()
|
||||
self.config.symbol_size = self.symbol_spin.value()
|
||||
|
||||
@@ -414,6 +414,8 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
Slot for when the axis settings dialog is closed.
|
||||
"""
|
||||
self.dap_summary.close()
|
||||
self.dap_summary.deleteLater()
|
||||
self.dap_summary_dialog.deleteLater()
|
||||
self.dap_summary_dialog = None
|
||||
self.toolbar.widgets["fit_params"].action.setChecked(False)
|
||||
@@ -1182,10 +1184,11 @@ class Waveform(PlotBase):
|
||||
self.on_async_readback,
|
||||
MessageEndpoints.device_async_readback(self.scan_id, name),
|
||||
from_start=True,
|
||||
cb_info={"scan_id": self.scan_id},
|
||||
)
|
||||
logger.info(f"Setup async curve {name}")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
@SafeSlot(dict, dict, verify_sender=True)
|
||||
def on_async_readback(self, msg, metadata):
|
||||
"""
|
||||
Get async data readback. This code needs to be fast, therefor we try
|
||||
@@ -1204,6 +1207,14 @@ class Waveform(PlotBase):
|
||||
msg(dict): Message with the async data.
|
||||
metadata(dict): Metadata of the message.
|
||||
"""
|
||||
sender = self.sender()
|
||||
if not hasattr(sender, "cb_info"):
|
||||
logger.info(f"Sender {sender} has no cb_info.")
|
||||
return
|
||||
scan_id = sender.cb_info.get("scan_id", None)
|
||||
if scan_id != self.scan_id:
|
||||
logger.info("Scan ID mismatch, ignoring async readback.")
|
||||
|
||||
instruction = metadata.get("async_update", {}).get("type")
|
||||
if instruction not in ["add", "add_slice", "replace"]:
|
||||
logger.warning(f"Invalid async update instruction: {instruction}")
|
||||
@@ -1212,6 +1223,7 @@ class Waveform(PlotBase):
|
||||
plot_mode = self.x_axis_mode["name"]
|
||||
for curve in self._async_curves:
|
||||
x_data = None # Reset x_data
|
||||
y_data = None # Reset y_data
|
||||
# Get the curve data
|
||||
async_data = msg["signals"].get(curve.config.signal.entry, None)
|
||||
if async_data is None:
|
||||
@@ -1456,7 +1468,7 @@ class Waveform(PlotBase):
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
|
||||
else: # history data
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0])
|
||||
new_suffix = f" [custom: {x_name}-{x_entry}]"
|
||||
new_suffix = f" (custom: {x_name}-{x_entry})"
|
||||
|
||||
# 2 User wants timestamp
|
||||
if self.x_axis_mode["name"] == "timestamp":
|
||||
@@ -1465,19 +1477,19 @@ class Waveform(PlotBase):
|
||||
else: # history data
|
||||
timestamps = data[device_name][device_entry].read().get("timestamp", [0])
|
||||
x_data = timestamps
|
||||
new_suffix = " [timestamp]"
|
||||
new_suffix = " (timestamp)"
|
||||
|
||||
# 3 User wants index
|
||||
if self.x_axis_mode["name"] == "index":
|
||||
x_data = None
|
||||
new_suffix = " [index]"
|
||||
new_suffix = " (index)"
|
||||
|
||||
# 4 Best effort automatic mode
|
||||
if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
|
||||
# 4.1 If there are async curves, use index
|
||||
if len(self._async_curves) > 0:
|
||||
x_data = None
|
||||
new_suffix = " [auto: index]"
|
||||
new_suffix = " (auto: index)"
|
||||
# 4.2 If there are sync curves, use the first device from the scan report
|
||||
else:
|
||||
try:
|
||||
@@ -1491,7 +1503,7 @@ class Waveform(PlotBase):
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
|
||||
else:
|
||||
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
|
||||
new_suffix = f" [auto: {x_name}-{x_entry}]"
|
||||
new_suffix = f" (auto: {x_name}-{x_entry})"
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
return x_data
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"set_minimum",
|
||||
"label_template",
|
||||
"label_template.setter",
|
||||
"_get_label",
|
||||
]
|
||||
ICON_NAME = "page_control"
|
||||
|
||||
@@ -235,6 +236,10 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
|
||||
)
|
||||
|
||||
def _get_label(self) -> str:
|
||||
"""Return the label text. mostly used for testing rpc."""
|
||||
return self.center_label.text()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
@@ -76,6 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
|
||||
|
||||
PLUGIN = True
|
||||
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
||||
USER_ACCESS = ["get_server_state", "remove"]
|
||||
|
||||
service_update = Signal(BECServiceInfoContainer)
|
||||
bec_core_state = Signal(str)
|
||||
@@ -134,6 +135,10 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
|
||||
"QTreeWidget::item:selected {}"
|
||||
)
|
||||
|
||||
def get_server_state(self) -> str:
|
||||
"""Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server"""
|
||||
return self.status_container[self.box_name]["info"].status
|
||||
|
||||
def _create_status_widget(
|
||||
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
|
||||
) -> StatusItem:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" Module for a StatusItem widget to display status and metrics for a BEC service.
|
||||
"""Module for a StatusItem widget to display status and metrics for a BEC service.
|
||||
The widget is bound to be used with the BECStatusBox widget."""
|
||||
|
||||
import enum
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" Utilities for filtering and formatting in the LogPanel"""
|
||||
"""Utilities for filtering and formatting in the LogPanel"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import get_theme_palette, set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
||||
@@ -69,22 +70,22 @@ DEFAULT_LOG_COLORS = {
|
||||
}
|
||||
|
||||
|
||||
class BecLogsQueue(QObject):
|
||||
class BecLogsQueue(BECConnector, QObject):
|
||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||
|
||||
RPC = False
|
||||
new_message = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None,
|
||||
conn: ConnectorBase,
|
||||
maxlen: int = 1000,
|
||||
line_formatter: LineFormatter = noop_format,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._timestamp_start: QDateTime | None = None
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
self._conn = conn
|
||||
self._conn = self.client.connector
|
||||
self._max_length = maxlen
|
||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
||||
self._display_queue: deque[str] = deque([], self._max_length)
|
||||
@@ -92,20 +93,27 @@ class BecLogsQueue(QObject):
|
||||
self._search_query: Pattern | str | None = None
|
||||
self._selected_services: set[str] | None = None
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
||||
self._callback = lambda *args: self._process_incoming_log_msg(*args)
|
||||
self._conn.register([MessageEndpoints.log()], None, self._callback)
|
||||
|
||||
def unsub_from_redis(self):
|
||||
def unsub_from_redis(self, *_):
|
||||
"""Stop listening to the Redis log stream"""
|
||||
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
self._conn.unregister([MessageEndpoints.log()], None, self._callback)
|
||||
|
||||
def _process_incoming_log_msg(self, msg: dict):
|
||||
try:
|
||||
_msg: LogMessage = msg["data"]
|
||||
_msg: LogMessage | None = msg.get("data", None)
|
||||
if _msg is None or not isinstance(_msg, LogMessage):
|
||||
return
|
||||
self._data.append(_msg)
|
||||
if self.filter is None or self.filter(_msg):
|
||||
self._display_queue.append(self._line_formatter(_msg))
|
||||
self.new_message.emit()
|
||||
except Exception as e:
|
||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||
self._conn.unregister([MessageEndpoints.log()], None, self._callback)
|
||||
return
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
@@ -407,17 +415,15 @@ class LogPanel(TextBox):
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the LogPanel widget."""
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
|
||||
self._update_colors()
|
||||
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
|
||||
self._log_manager = BecLogsQueue(
|
||||
parent,
|
||||
self.client.connector, # type: ignore
|
||||
line_formatter=partial(simple_color_format, colors=self._colors),
|
||||
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
|
||||
)
|
||||
self._log_manager.new_message.connect(self._new_messages)
|
||||
|
||||
self.toolbar = LogPanelToolbar(parent=parent)
|
||||
self.toolbar = LogPanelToolbar(parent=self)
|
||||
self.toolbar_area = QScrollArea()
|
||||
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
|
||||
@@ -529,9 +535,9 @@ class LogPanel(TextBox):
|
||||
|
||||
def cleanup(self):
|
||||
self._service_status.cleanup()
|
||||
self._log_manager.new_message.disconnect()
|
||||
self._new_messages.disconnect()
|
||||
self._log_manager.unsub_from_redis()
|
||||
self._log_manager.new_message.disconnect(self._new_messages)
|
||||
self._new_messages.disconnect(self._on_append)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ColorButtonNative(BECWidget, QPushButton):
|
||||
"""A QPushButton subclass that displays a color.
|
||||
|
||||
The background is set to the given color and the button text is the hex code.
|
||||
The text color is chosen automatically (black if the background is light, white if dark)
|
||||
to guarantee good readability.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = True
|
||||
ICON_NAME = "colors"
|
||||
|
||||
def __init__(self, parent=None, color="#000000", **kwargs):
|
||||
"""Initialize the color button.
|
||||
|
||||
Args:
|
||||
parent: Optional QWidget parent.
|
||||
color (str): The initial color in hex format (e.g., '#000000').
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.set_color(color)
|
||||
|
||||
@SafeSlot()
|
||||
def set_color(self, color):
|
||||
"""Set the button's color and update its appearance.
|
||||
|
||||
Args:
|
||||
color (str or QColor): The new color to assign.
|
||||
"""
|
||||
if isinstance(color, QColor):
|
||||
self._color = color.name()
|
||||
else:
|
||||
self._color = color
|
||||
self._update_appearance()
|
||||
|
||||
@SafeProperty("QColor")
|
||||
def color(self):
|
||||
"""Return the current color in hex."""
|
||||
return self._color
|
||||
|
||||
@color.setter
|
||||
def color(self, value):
|
||||
"""Set the button's color and update its appearance."""
|
||||
self.set_color(value)
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the button style based on the background color's brightness."""
|
||||
c = QColor(self._color)
|
||||
brightness = c.lightnessF()
|
||||
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
|
||||
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
|
||||
self.setText(self._color)
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['color_button_native.py']}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
|
||||
ColorButtonNative,
|
||||
)
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ColorButtonNative' name='color_button_native'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ColorButtonNativePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ColorButtonNative(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Buttons"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ColorButtonNative.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "color_button_native"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ColorButtonNative"
|
||||
|
||||
def toolTip(self):
|
||||
return "A QPushButton subclass that displays a color."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native_plugin import (
|
||||
ColorButtonNativePlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonNativePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,4 +1,5 @@
|
||||
from pyqtgraph.widgets.ColorMapButton import ColorMapButton
|
||||
from qtpy import QtCore, QtGui
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
@@ -6,6 +7,23 @@ from bec_widgets.utils import Colors
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
class RoundedColorMapButton(ColorMapButton):
|
||||
"""Thin wrapper around pyqtgraph ColorMapButton to add rounded clipping."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
||||
|
||||
def paintEvent(self, evt):
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
path = QtGui.QPainterPath()
|
||||
path.addRoundedRect(self.rect(), 8, 8)
|
||||
painter.setClipPath(path)
|
||||
self.paintColorMap(painter, self.contentsRect())
|
||||
painter.end()
|
||||
|
||||
|
||||
class BECColorMapWidget(BECWidget, QWidget):
|
||||
colormap_changed_signal = Signal(str)
|
||||
ICON_NAME = "palette"
|
||||
@@ -15,7 +33,7 @@ class BECColorMapWidget(BECWidget, QWidget):
|
||||
def __init__(self, parent=None, cmap: str = "plasma", **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
# Create the ColorMapButton
|
||||
self.button = ColorMapButton()
|
||||
self.button = RoundedColorMapButton()
|
||||
|
||||
# Set the size policy and minimum width
|
||||
size_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
@@ -19,13 +19,11 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
class HelloWorldWidget(BECWidget, QWidget):
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, client=None, gui_id: str | None = None
|
||||
self, parent: QWidget | None = None, client=None, gui_id: str | None = None, **kwargs
|
||||
) -> None:
|
||||
# Initialize the BECWidget and QWidget
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent)
|
||||
# Initialize base classes
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Create a label with the text "Hello World"
|
||||
self.label = QLabel(self)
|
||||
self.label.setText("Hello World")
|
||||
|
||||
|
||||
@@ -7,6 +7,4 @@ sphinx-copybutton
|
||||
sphinx-inline-tabs
|
||||
myst-parser
|
||||
sphinx-design
|
||||
PySide6~=6.8.2
|
||||
bec-widgets
|
||||
tomli
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.0.1"
|
||||
version = "2.8.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -16,7 +16,7 @@ dependencies = [
|
||||
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.29, <=4.0",
|
||||
"bec_qthemes~=0.7, >=0.7",
|
||||
"black~=24.0", # needed for bw-generate-cli
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
@@ -31,12 +31,14 @@ dependencies = [
|
||||
dev = [
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"isort~=5.13, >=5.13.2",
|
||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
"pytest-timeout~=2.2",
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -69,7 +71,7 @@ include_trailing_comma = true
|
||||
known_first_party = ["bec_widgets"]
|
||||
|
||||
[tool.semantic_release]
|
||||
build_command = "python -m build"
|
||||
build_command = "pip install build wheel && python -m build"
|
||||
version_toml = ["pyproject.toml:project.version"]
|
||||
|
||||
[tool.semantic_release.commit_author]
|
||||
@@ -95,12 +97,23 @@ default_bump_level = 0
|
||||
|
||||
[tool.semantic_release.remote]
|
||||
name = "origin"
|
||||
type = "gitlab"
|
||||
ignore_token_for_push = false
|
||||
type = "github"
|
||||
ignore_token_for_push = true
|
||||
|
||||
[tool.semantic_release.remote.token]
|
||||
env = "GL_TOKEN"
|
||||
env = "GH_TOKEN"
|
||||
|
||||
[tool.semantic_release.publish]
|
||||
dist_glob_patterns = ["dist/*"]
|
||||
upload_to_vcs_release = true
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"return NotImplemented",
|
||||
"raise NotImplementedError",
|
||||
"\\.\\.\\.",
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
|
||||
@@ -4,13 +4,21 @@ import random
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def threads_check_fixture(threads_check):
|
||||
"""
|
||||
@@ -28,7 +36,7 @@ def gui_id():
|
||||
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="function")
|
||||
def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
|
||||
"""
|
||||
Fixture to create a new BECGuiClient object and start a server in the background.
|
||||
@@ -42,22 +50,3 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
|
||||
yield gui
|
||||
finally:
|
||||
gui.kill_server()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def connected_gui_with_scope_session(qtbot, gui_id, bec_client_lib):
|
||||
"""
|
||||
Fixture to create a new BECGuiClient object and start a server in the background.
|
||||
|
||||
This fixture is scoped to the session, meaning it remains alive for all tests in the session.
|
||||
We can use this fixture to create a gui object that is used across multiple tests, and
|
||||
simulate a real-world scenario where the gui is not restarted for each test.
|
||||
"""
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.start(wait=True)
|
||||
# After the server started, we need to wait until the bec exists in the namespace
|
||||
qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
|
||||
yield gui
|
||||
finally:
|
||||
gui.kill_server()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test module for the gui object within the BEC IPython client.
|
||||
Test module for the gui object within the BEC IPython client.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -136,7 +136,7 @@ def test_async_plotting(qtbot, bec_client_lib, connected_client_gui_obj):
|
||||
dev.waveform.sim.select_model("GaussianModel")
|
||||
dev.waveform.sim.params = {"amplitude": 1000, "center": 4000, "sigma": 300}
|
||||
dev.waveform.async_update.set("add").wait()
|
||||
dev.waveform.waveform_shape.set(1000).wait()
|
||||
dev.waveform.waveform_shape.set(10000).wait()
|
||||
wf = dock.new("wf_dock").new("Waveform")
|
||||
curve = wf.plot(y_name="waveform")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from bec_widgets.cli.rpc.rpc_base import RPCReference
|
||||
|
||||
def test_rpc_reference_objects(connected_client_gui_obj):
|
||||
gui = connected_client_gui_obj
|
||||
dock = gui.window_list[0].new("dock")
|
||||
dock = gui.window_list[0].new()
|
||||
plt = dock.new(name="fig", widget="Waveform")
|
||||
|
||||
plt.plot(x_name="samx", y_name="bpm4i")
|
||||
|
||||
@@ -96,6 +96,10 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
||||
if object_name == "VSCodeEditor":
|
||||
continue
|
||||
|
||||
# Skip WebConsole as ttyd is not installed
|
||||
if object_name == "WebConsole":
|
||||
continue
|
||||
|
||||
#############################
|
||||
######### Add widget ########
|
||||
#############################
|
||||
|
||||
@@ -3,15 +3,6 @@ import time
|
||||
import pytest
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_scan_control_populate_scans_e2e(scan_control):
|
||||
@@ -27,6 +18,7 @@ def test_scan_control_populate_scans_e2e(scan_control):
|
||||
"monitor_scan",
|
||||
"acquire",
|
||||
"line_scan",
|
||||
"custom_testing_scan",
|
||||
]
|
||||
items = [
|
||||
scan_control.comboBox_scan_selection.itemText(i)
|
||||
|
||||
89
tests/end-2-end/test_with_plugins_e2e.py
Normal file
89
tests/end-2-end/test_with_plugins_e2e.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from bec_testing_plugin.scans.metadata_schema.custom_test_scan_schema import CustomScanSchema
|
||||
from qtpy.QtWidgets import QGridLayout
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["md", "valid"],
|
||||
[
|
||||
({"treatment_description": "soaking", "treatment_temperature_k": 123}, True),
|
||||
({"treatment_description": "soaking", "treatment_temperature_k": "wrong type"}, False),
|
||||
({"treatment_description": "soaking", "wrong key": 123}, False),
|
||||
(
|
||||
{
|
||||
"sample_name": "test sample",
|
||||
"treatment_description": "soaking",
|
||||
"treatment_temperature_k": 123,
|
||||
},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_scan_metadata_for_custom_scan(
|
||||
scan_control: ScanControl, bec_client_lib, qtbot, md: dict, valid: bool
|
||||
):
|
||||
client = bec_client_lib
|
||||
queue = client.queue
|
||||
|
||||
scan_name = "custom_testing_scan"
|
||||
kwargs = {"exp_time": 0.01, "steps": 10, "relative": True, "burst_at_each_point": 1}
|
||||
args = {"device": "samx", "start": -5, "stop": 5}
|
||||
|
||||
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
# Set kwargs in the UI
|
||||
for kwarg_box in scan_control.kwarg_boxes:
|
||||
for widget in kwarg_box.widgets:
|
||||
for key, value in kwargs.items():
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
# Set args in the UI
|
||||
for widget in scan_control.arg_box.widgets:
|
||||
for key, value in args.items():
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
assert scan_control._metadata_form._md_schema == CustomScanSchema
|
||||
assert not scan_control.button_run_scan.isEnabled()
|
||||
|
||||
def do_test():
|
||||
# Set the metadata
|
||||
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
|
||||
for i in range(grid.rowCount()): # type: ignore
|
||||
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
|
||||
if (value_to_set := md.pop(field_name, None)) is not None:
|
||||
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
|
||||
# all values should be used
|
||||
assert md == {}
|
||||
assert scan_control.button_run_scan.isEnabled()
|
||||
|
||||
# Run the scan
|
||||
scan_control.button_run_scan.click()
|
||||
time.sleep(2)
|
||||
|
||||
last_scan = queue.scan_storage.storage[-1]
|
||||
assert last_scan.status_message.info["scan_name"] == scan_name
|
||||
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
|
||||
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
|
||||
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
|
||||
|
||||
if valid:
|
||||
do_test()
|
||||
else:
|
||||
with pytest.raises(Exception):
|
||||
do_test()
|
||||
0
tests/end-2-end/user_interaction/__init__.py
Normal file
0
tests/end-2-end/user_interaction/__init__.py
Normal file
82
tests/end-2-end/user_interaction/conftest.py
Normal file
82
tests/end-2-end/user_interaction/conftest.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
End-2-End test fixtures for module scoped testing. The fixtures overwrite the default versions used
|
||||
for the function scoped tests. The fixtures will only be created once for this entire module, meaning
|
||||
that any test can be used to test user interaction and potential leakage of threads or other resources across
|
||||
different widgets.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from bec_ipython_client import BECIPythonClient
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib.tests.utils import wait_for_empty_queue
|
||||
from pytestqt.plugin import QtBot
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def gui_id():
|
||||
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
|
||||
return f"figure_{random.randint(0,100)}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bec_ipython_client_with_demo_config(
|
||||
bec_redis_fixture, bec_services_config_file_path, bec_servers
|
||||
):
|
||||
"""Fixture to create a BECIPythonClient with a demo config."""
|
||||
config = ServiceConfig(bec_services_config_file_path)
|
||||
bec = BECIPythonClient(config, RedisConnector, forced=True)
|
||||
bec.start()
|
||||
bec.config.load_demo_config()
|
||||
try:
|
||||
yield bec
|
||||
finally:
|
||||
bec.shutdown()
|
||||
bec._client._reset_singleton()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bec_client_lib(bec_ipython_client_with_demo_config):
|
||||
"""Fixture to create a BECIPythonClient with a demo config."""
|
||||
bec = bec_ipython_client_with_demo_config
|
||||
bec.queue.request_queue_reset()
|
||||
bec.queue.request_scan_continuation()
|
||||
wait_for_empty_queue(bec)
|
||||
yield bec
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qtbot_scope_module(qapp, request):
|
||||
"""
|
||||
Fixture used to create a QtBot instance for using during testing.
|
||||
|
||||
Make sure to call addWidget for each top-level widget you create to ensure
|
||||
that they are properly closed after the test ends.
|
||||
"""
|
||||
result = QtBot(request)
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib):
|
||||
"""
|
||||
Fixture to create a new BECGuiClient object and start a server in the background.
|
||||
|
||||
This fixture is scoped to the session, meaning it remains alive for all tests in the session.
|
||||
We can use this fixture to create a gui object that is used across multiple tests, and
|
||||
simulate a real-world scenario where the gui is not restarted for each test.
|
||||
"""
|
||||
gui = BECGuiClient(gui_id=gui_id)
|
||||
try:
|
||||
gui.start(wait=True)
|
||||
qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
|
||||
yield gui
|
||||
finally:
|
||||
gui.kill_server()
|
||||
724
tests/end-2-end/user_interaction/test_user_interaction_e2e.py
Normal file
724
tests/end-2-end/user_interaction/test_user_interaction_e2e.py
Normal file
@@ -0,0 +1,724 @@
|
||||
"""
|
||||
End-to-end tests single gui instance across the full session.
|
||||
|
||||
Each test will use the same gui instance, simulating a real-world scenario where the gui is not
|
||||
restarted for each test. The interaction is tested through the rpc calls.
|
||||
|
||||
Note: wait_for_namespace_created is a utility method that helps to wait for the namespace to be
|
||||
created in the gui. This is necessary because the rpc calls are asynchronous and the namespace
|
||||
may not be created immediately after the rpc call is made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
|
||||
PYTEST_TIMEOUT = 50
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.cli import client
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=too-many-arguments
|
||||
# pylint: disable=protected-access
|
||||
# pylint: disable=unused-variable
|
||||
|
||||
|
||||
def wait_for_namespace_change(
|
||||
qtbot,
|
||||
gui: BECGuiClient,
|
||||
parent_widget: RPCBase | RPCReference,
|
||||
object_name: str,
|
||||
widget_gui_id: str,
|
||||
timeout: float = 10000,
|
||||
exists: bool = True,
|
||||
):
|
||||
"""
|
||||
Utility method to wait for the namespace to be created in the widget.
|
||||
|
||||
Args:
|
||||
qtbot: The qtbot fixture.
|
||||
gui: The client_utils.BECGuiClient 'gui' object from the CLI.
|
||||
parent_widget: The widget that creates a new widget.
|
||||
object_name: The name of the widget that was created. Must appear as attribute in namespace of parent.
|
||||
widget_gui_id: The gui_id of the created widget.
|
||||
timeout: The timeout in milliseconds for the qtbot to wait for changes to appear.
|
||||
exists: If True, wait for the object to be created. If False, wait for the object to be removed.
|
||||
"""
|
||||
# GUI object is not registered in the registry (yet)
|
||||
if parent_widget is gui:
|
||||
|
||||
def check_reference_registered():
|
||||
# Check server registry
|
||||
obj = gui._server_registry.get(widget_gui_id, None)
|
||||
if obj is None:
|
||||
if not exists:
|
||||
return True
|
||||
return False
|
||||
# CHeck Ipython registry
|
||||
obj = gui._ipython_registry.get(widget_gui_id, None)
|
||||
if obj is None:
|
||||
if not exists:
|
||||
return True
|
||||
return False
|
||||
|
||||
else:
|
||||
|
||||
def check_reference_registered():
|
||||
# Check server registry
|
||||
obj = gui._server_registry.get(widget_gui_id, None)
|
||||
if obj is None:
|
||||
if not exists:
|
||||
return True
|
||||
return False
|
||||
# CHeck Ipython registry
|
||||
obj = gui._ipython_registry.get(widget_gui_id, None)
|
||||
if obj is None:
|
||||
if not exists:
|
||||
return True
|
||||
return False
|
||||
# Check reference registry
|
||||
ref = parent_widget._rpc_references.get(widget_gui_id, None)
|
||||
if exists:
|
||||
return ref is not None
|
||||
return ref is None
|
||||
|
||||
try:
|
||||
qtbot.waitUntil(check_reference_registered, timeout=timeout)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Timeout waiting for {parent_widget.object_name}.{object_name} to be created."
|
||||
) from e
|
||||
|
||||
|
||||
def create_widget(
|
||||
qtbot, gui: BECGuiClient, widget_cls_name: str
|
||||
) -> tuple[RPCReference, RPCReference]:
|
||||
"""Utility method to create a widget and wait for the namespaces to be created."""
|
||||
if hasattr(gui, "dock_area"):
|
||||
dock_area: client.BECDockArea = gui.dock_area
|
||||
else:
|
||||
dock_area: client.BECDockArea = gui.new(name="dock_area")
|
||||
wait_for_namespace_change(qtbot, gui, gui, dock_area.object_name, dock_area._gui_id)
|
||||
dock: client.BECDock = dock_area.new()
|
||||
wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id)
|
||||
widget = dock.new(widget=widget_cls_name)
|
||||
wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id)
|
||||
return dock, widget
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def random_generator_from_seed(request):
|
||||
"""Fixture to get a random seed for the following tests."""
|
||||
seed = request.config.getoption("--random-order-seed").split(":")[-1]
|
||||
try:
|
||||
seed = int(seed)
|
||||
except ValueError: # Should not be required...
|
||||
seed = 42
|
||||
rng = random.Random(seed)
|
||||
yield rng
|
||||
|
||||
|
||||
def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Random):
|
||||
"""Utility method to remove all dock_ares from gui object, likelihood 50%."""
|
||||
random_int = random_int_gen.randint(0, 100)
|
||||
if random_int >= 50:
|
||||
# Needed, reference gets deleted in the gui
|
||||
name = gui.dock_area.object_name
|
||||
gui_id = gui.dock_area._gui_id
|
||||
gui.delete("dock_area")
|
||||
wait_for_namespace_change(
|
||||
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the AbortButton widget."""
|
||||
gui: BECGuiClient = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton)
|
||||
dock: client.BECDock
|
||||
widget: client.AbortButton
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Try detaching the dock
|
||||
dock.detach()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECProgressBar widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
|
||||
dock: client.BECDock
|
||||
widget: client.BECProgressBar
|
||||
|
||||
# Check rpc calls
|
||||
assert widget.label_template == "$value / $maximum - $percentage %"
|
||||
widget.set_maximum(100)
|
||||
widget.set_minimum(50)
|
||||
widget.set_value(75)
|
||||
|
||||
assert widget._get_label() == "75 / 100 - 50 %"
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECQueue widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
|
||||
dock: client.BECDock
|
||||
widget: client.BECQueue
|
||||
|
||||
# No rpc calls to test so far
|
||||
# maybe we can add an rpc call to check the queue length
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECStatusBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
|
||||
|
||||
# Check rpc calls
|
||||
assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DAPComboBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
|
||||
dock: client.BECDock
|
||||
widget: client.DAPComboBox
|
||||
|
||||
# Check rpc calls
|
||||
widget.select_fit_model("PseudoVoigtModel")
|
||||
widget.select_x_axis("samx")
|
||||
widget.select_y_axis("bpm4i")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceBrowser widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
|
||||
dock: client.BECDock
|
||||
widget: client.DeviceBrowser
|
||||
|
||||
# No rpc calls yet to check
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceComboBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox)
|
||||
dock: client.BECDock
|
||||
widget: client.DeviceComboBox
|
||||
|
||||
assert "samx" in widget.devices
|
||||
assert "bpm4i" in widget.devices
|
||||
|
||||
widget.set_device("samx")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceLineEdit widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit)
|
||||
dock: client.BECDock
|
||||
widget: client.DeviceLineEdit
|
||||
|
||||
assert widget._is_valid_input is False
|
||||
assert "samx" in widget.devices
|
||||
assert "bpm4i" in widget.devices
|
||||
|
||||
widget.set_device("samx")
|
||||
assert widget._is_valid_input is True
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_signal_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceSignalLineEdit widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalLineEdit)
|
||||
dock: client.BECDock
|
||||
widget: client.SignalLineEdit
|
||||
|
||||
widget.set_device("samx")
|
||||
assert widget._is_valid_input is False
|
||||
assert widget.signals == [
|
||||
"readback",
|
||||
"setpoint",
|
||||
"motor_is_moving",
|
||||
"velocity",
|
||||
"acceleration",
|
||||
"tolerance",
|
||||
]
|
||||
widget.set_signal("readback")
|
||||
assert widget._is_valid_input is True
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceSignalComboBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
|
||||
dock: client.BECDock
|
||||
widget: client.SignalComboBox
|
||||
|
||||
widget.set_device("samx")
|
||||
assert widget.signals == [
|
||||
"readback",
|
||||
"setpoint",
|
||||
"motor_is_moving",
|
||||
"velocity",
|
||||
"acceleration",
|
||||
"tolerance",
|
||||
]
|
||||
widget.set_signal("readback")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the Image widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Image)
|
||||
dock: client.BECDock
|
||||
widget: client.Image
|
||||
|
||||
scans = bec.scans
|
||||
dev = bec.device_manager.devices
|
||||
# Test rpc calls
|
||||
img = widget.image(dev.eiger)
|
||||
assert img.get_data() is None
|
||||
# Run a scan and plot the image
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# Check that last image is equivalent to data in Redis
|
||||
last_img = bec.device_monitor.get_data(
|
||||
dev.eiger, count=1
|
||||
) # Get last image from Redis monitor 2D endpoint
|
||||
assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# TODO re-enable when issue is resolved #560
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the LogPanel widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area, dock, widget
|
||||
# dock, widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
||||
# dock: client.BECDock
|
||||
# widget: client.LogPanel
|
||||
|
||||
# # No rpc calls to check so far
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MineSweeper widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
|
||||
dock: client.BECDock
|
||||
widget: client.MineSweeper
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MotorMap widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
|
||||
dock: client.BECDock
|
||||
widget: client.MotorMap
|
||||
|
||||
# Test RPC calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Set motor map to names
|
||||
widget.map(dev.samx, dev.samy)
|
||||
# Move motor samx to pos
|
||||
pos = dev.samx.limits[1] - 1 # -1 from higher limit
|
||||
scans.mv(dev.samx, pos, relative=False).wait()
|
||||
# Check that data is up to date
|
||||
assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
|
||||
# Move motor samy to pos
|
||||
pos = dev.samy.limits[0] + 1 # +1 from lower limit
|
||||
scans.mv(dev.samy, pos, relative=False).wait()
|
||||
# Check that data is up to date
|
||||
assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test MultiWaveform widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
|
||||
dock: client.BECDock
|
||||
widget: client.MultiWaveform
|
||||
|
||||
# Test RPC calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# test plotting
|
||||
cm = "cividis"
|
||||
widget.plot(dev.waveform, color_palette=cm)
|
||||
assert widget.monitor == dev.waveform.name
|
||||
assert widget.color_palette == cm
|
||||
|
||||
# Scan with BEC
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
# Wait for data in history (should be plotted?)
|
||||
|
||||
# TODO how can we check that the data was plotted, implement get_data()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_indicator(
|
||||
qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
):
|
||||
"""Test the PositionIndicator widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
|
||||
dock: client.BECDock
|
||||
widget: client.PositionIndicator
|
||||
|
||||
# TODO check what these rpc calls are supposed to do! Issue created #461
|
||||
widget.set_value(5)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the PositionerBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
|
||||
dock: client.BECDock
|
||||
widget: client.PositionerBox
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# No rpc calls to check so far
|
||||
widget.set_positioner(dev.samx)
|
||||
widget.set_positioner(dev.samy.name)
|
||||
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the PositionerBox2D widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
|
||||
dock: client.BECDock
|
||||
widget: client.PositionerBox2D
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# No rpc calls to check so far
|
||||
widget.set_positioner_hor(dev.samx)
|
||||
widget.set_positioner_ver(dev.samy)
|
||||
|
||||
# Try moving the motors
|
||||
scans.mv(dev.samx, 3, relative=False).wait()
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_control_line(
|
||||
qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
):
|
||||
"""Test the positioner control line widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
|
||||
dock: client.BECDock
|
||||
widget: client.PositionerControlLine
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Set positioner
|
||||
widget.set_positioner(dev.samx)
|
||||
scans.mv(dev.samx, 3, relative=False).wait()
|
||||
widget.set_positioner(dev.samy.name)
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the RingProgressBar widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar)
|
||||
dock: client.BECDock
|
||||
widget: client.RingProgressBar
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Do a scan
|
||||
scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the ScanControl widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
|
||||
dock: client.BECDock
|
||||
widget: client.ScanControl
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the ScatterWaveform widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
|
||||
dock: client.BECDock
|
||||
widget: client.ScatterWaveform
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
widget.plot(dev.samx, dev.samy, dev.bpm4i)
|
||||
scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the StopButton widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton)
|
||||
dock: client.BECDock
|
||||
widget: client.StopButton
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the StopButton widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton)
|
||||
dock: client.BECDock
|
||||
widget: client.ResumeButton
|
||||
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the StopButton widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton)
|
||||
dock: client.BECDock
|
||||
widget: client.ResetButton
|
||||
# No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the TextBox widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
|
||||
dock: client.BECDock
|
||||
widget: client.TextBox
|
||||
|
||||
# RPC calls
|
||||
widget.set_plain_text("Hello World")
|
||||
widget.set_html_text("<b> Hello World HTML </b>")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the Waveform widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area, dock, widget
|
||||
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
|
||||
dock: client.BECDock
|
||||
widget: client.Waveform
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
widget.plot(dev.bpm4i)
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
samx_data = scan_item.devices.samx.samx.read()["value"]
|
||||
bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
|
||||
curve = widget.curves[0]
|
||||
assert np.allclose(curve.get_data()[0], samx_data)
|
||||
assert np.allclose(curve.get_data()[1], bpm4i_data)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user