mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 18:20:55 +02:00
Compare commits
93 Commits
v2.2.0
...
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 |
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
|
||||
|
||||
@@ -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]
|
||||
|
||||
3434
CHANGELOG.md
3434
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. 🚨
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
@@ -44,6 +44,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class LaunchTile(RoundedFrame):
|
||||
DEFAULT_SIZE = (250, 300)
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
@@ -54,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)
|
||||
@@ -87,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)
|
||||
@@ -133,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
|
||||
@@ -146,6 +190,8 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
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)
|
||||
@@ -196,7 +242,7 @@ class LaunchWindow(BECMainWindow):
|
||||
)
|
||||
|
||||
# plugin widgets
|
||||
self.available_widgets: dict[str, BECWidget] = get_all_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()
|
||||
@@ -250,14 +296,34 @@ class LaunchWindow(BECMainWindow):
|
||||
main_label=main_label,
|
||||
description=description,
|
||||
show_selector=show_selector,
|
||||
tile_size=self.TILE_SIZE,
|
||||
)
|
||||
tile.setFixedSize(*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(
|
||||
|
||||
@@ -51,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)
|
||||
|
||||
@@ -503,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):
|
||||
@@ -720,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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -740,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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1214,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
|
||||
@@ -2317,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."""
|
||||
|
||||
@@ -2981,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."""
|
||||
|
||||
@@ -3501,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"""
|
||||
|
||||
|
||||
@@ -111,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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ...]:
|
||||
@@ -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())
|
||||
...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_()
|
||||
|
||||
@@ -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_())
|
||||
|
||||
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)
|
||||
|
||||
@@ -112,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)
|
||||
@@ -473,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):
|
||||
"""
|
||||
@@ -521,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):
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -1466,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":
|
||||
@@ -1475,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:
|
||||
@@ -1501,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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.2.0"
|
||||
version = "2.8.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -38,6 +38,7 @@ dev = [
|
||||
"pytest-timeout~=2.2",
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,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]
|
||||
@@ -96,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__":',
|
||||
]
|
||||
|
||||
@@ -5,11 +5,20 @@ import random
|
||||
import pytest
|
||||
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -258,7 +258,10 @@ def test_widgets_e2e_device_combo_box(qtbot, connected_client_gui_obj, random_ge
|
||||
dock: client.BECDock
|
||||
widget: client.DeviceComboBox
|
||||
|
||||
# No rpc calls to check so far, maybe set_device should be exposed
|
||||
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)
|
||||
@@ -274,10 +277,64 @@ def test_widgets_e2e_device_line_edit(qtbot, connected_client_gui_obj, random_ge
|
||||
dock: client.BECDock
|
||||
widget: client.DeviceLineEdit
|
||||
|
||||
# No rpc calls to check so far
|
||||
# Should probably have a set_device method
|
||||
assert widget._is_valid_input is False
|
||||
assert "samx" in widget.devices
|
||||
assert "bpm4i" in widget.devices
|
||||
|
||||
# No rpc calls to check so far, maybe set_device should be exposed
|
||||
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)
|
||||
|
||||
@@ -30,7 +30,7 @@ def mocked_client(bec_dispatcher):
|
||||
# Mock the device_manager.devices attribute
|
||||
client.connector = connector
|
||||
client.device_manager = DMMock()
|
||||
client.device_manager.add_devives(DEVICES)
|
||||
client.device_manager.add_devices(DEVICES)
|
||||
|
||||
def mock_mv(*args, relative=False):
|
||||
# Extracting motor and value pairs
|
||||
|
||||
@@ -82,3 +82,52 @@ def test_bec_connector_submit_task(bec_connector):
|
||||
while not completed:
|
||||
QApplication.processEvents()
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def test_bec_connector_change_object_name(bec_connector):
|
||||
# Store the original object name and RPC register state
|
||||
original_name = bec_connector.objectName()
|
||||
original_gui_id = bec_connector.gui_id
|
||||
|
||||
# Call the method with a new name
|
||||
new_name = "new_test_name"
|
||||
bec_connector.change_object_name(new_name)
|
||||
|
||||
# Process events to allow the single shot timer to execute
|
||||
QApplication.processEvents()
|
||||
|
||||
# Verify that the object name was changed correctly
|
||||
assert bec_connector.objectName() == new_name
|
||||
assert bec_connector.object_name == new_name
|
||||
|
||||
# Verify that the object is registered in the RPC register with the new name
|
||||
assert bec_connector.rpc_register.object_is_registered(bec_connector)
|
||||
|
||||
# Verify that the object with the original name is no longer registered
|
||||
# The object should still have the same gui_id
|
||||
assert bec_connector.gui_id == original_gui_id
|
||||
# Check that no object with the original name exists in the RPC register
|
||||
all_objects = bec_connector.rpc_register.list_all_connections().values()
|
||||
assert not any(obj.objectName() == original_name for obj in all_objects)
|
||||
|
||||
# Store the current name for the next test
|
||||
previous_name = bec_connector.objectName()
|
||||
|
||||
# Test with spaces and hyphens
|
||||
name_with_spaces_and_hyphens = "test name-with-hyphens"
|
||||
expected_name = "test_name_with_hyphens"
|
||||
bec_connector.change_object_name(name_with_spaces_and_hyphens)
|
||||
|
||||
# Process events to allow the single shot timer to execute
|
||||
QApplication.processEvents()
|
||||
|
||||
# Verify that the object name was changed correctly with replacements
|
||||
assert bec_connector.objectName() == expected_name
|
||||
assert bec_connector.object_name == expected_name
|
||||
|
||||
# Verify that the object is still registered in the RPC register after the second name change
|
||||
assert bec_connector.rpc_register.object_is_registered(bec_connector)
|
||||
|
||||
# Verify that the object with the previous name is no longer registered
|
||||
all_objects = bec_connector.rpc_register.list_all_connections().values()
|
||||
assert not any(obj.objectName() == previous_name for obj in all_objects)
|
||||
|
||||
@@ -2,7 +2,9 @@ from importlib.machinery import FileFinder, SourceFileLoader
|
||||
from types import ModuleType
|
||||
from unittest import mock
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import BECWidget, _all_widgets_from_all_submods
|
||||
from bec_widgets.utils.bec_plugin_helper import _all_widgets_from_all_submods
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
|
||||
def test_all_widgets_from_module_no_submodules():
|
||||
@@ -39,10 +41,17 @@ def test_all_widgets_from_module_with_submodules():
|
||||
mock.patch("importlib.util.module_from_spec", return_value=submodule),
|
||||
mock.patch(
|
||||
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
|
||||
side_effect=[{"TestWidget": BECWidget}, {"SubWidget": BECWidget}],
|
||||
side_effect=[
|
||||
BECClassContainer(
|
||||
[BECClassInfo(name="TestWidget", module="", obj=BECWidget, file="")]
|
||||
),
|
||||
BECClassContainer(
|
||||
[BECClassInfo(name="SubWidget", module="", obj=BECWidget, file="")]
|
||||
),
|
||||
],
|
||||
),
|
||||
):
|
||||
widgets = _all_widgets_from_all_submods(module)
|
||||
widgets = _all_widgets_from_all_submods(module).as_dict()
|
||||
|
||||
assert widgets == {"TestWidget": BECWidget, "SubWidget": BECWidget}
|
||||
|
||||
@@ -54,8 +63,9 @@ def test_all_widgets_from_module_no_widgets():
|
||||
module = mock.MagicMock()
|
||||
|
||||
with mock.patch(
|
||||
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module", return_value={}
|
||||
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
|
||||
return_value=BECClassContainer([]),
|
||||
):
|
||||
widgets = _all_widgets_from_all_submods(module)
|
||||
widgets = _all_widgets_from_all_submods(module).as_dict()
|
||||
|
||||
assert widgets == {}
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from bec_widgets.cli import client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
|
||||
class _TestGlobalPlugin(RPCBase): ...
|
||||
@@ -47,7 +48,9 @@ mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
|
||||
)
|
||||
@patch(
|
||||
"bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets",
|
||||
return_value={"DeviceComboBox": _TestDuplicatePlugin},
|
||||
return_value=BECClassContainer(
|
||||
[BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")]
|
||||
),
|
||||
)
|
||||
def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
|
||||
reload(client)
|
||||
|
||||
@@ -7,6 +7,7 @@ from qtpy.QtWidgets import QWidget
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
DeviceInputBase,
|
||||
DeviceInputConfig,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
@@ -51,9 +52,13 @@ def test_device_input_base_init_with_config(mocked_client):
|
||||
"default": "samx",
|
||||
}
|
||||
widget = DeviceInputWidget(client=mocked_client, config=config)
|
||||
assert widget.config.gui_id == "test_gui_id"
|
||||
assert widget.config.device_filter == ["Positioner"]
|
||||
assert widget.config.default == "samx"
|
||||
widget2 = DeviceInputWidget(
|
||||
client=mocked_client, config=DeviceInputConfig.model_validate(config)
|
||||
)
|
||||
for w in [widget, widget2]:
|
||||
assert w.config.gui_id == "test_gui_id"
|
||||
assert w.config.device_filter == ["Positioner"]
|
||||
assert w.config.default == "samx"
|
||||
|
||||
|
||||
def test_device_input_base_set_device_filter(device_input_base):
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib.device import Signal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.tests.utils import FakeDevice
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
@@ -18,6 +20,10 @@ from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
class FakeSignal(Signal):
|
||||
"""Fake signal to test the DeviceSignalInputBase."""
|
||||
|
||||
|
||||
class DeviceInputWidget(DeviceSignalInputBase, QWidget):
|
||||
"""Thin wrapper around DeviceInputBase to make it a QWidget"""
|
||||
|
||||
@@ -107,6 +113,14 @@ def test_signal_combobox(qtbot, device_signal_combobox):
|
||||
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"]
|
||||
qtbot.wait(100)
|
||||
assert container == ["samx"]
|
||||
# Set the type of class from the FakeDevice to Signal
|
||||
fake_signal = FakeSignal(name="fake_signal")
|
||||
device_signal_combobox.client.device_manager.add_devices([fake_signal])
|
||||
device_signal_combobox.set_device("fake_signal")
|
||||
assert device_signal_combobox.signals == ["fake_signal"]
|
||||
assert device_signal_combobox._config_signals == []
|
||||
assert device_signal_combobox._normal_signals == []
|
||||
assert device_signal_combobox._hinted_signals == ["fake_signal"]
|
||||
|
||||
|
||||
def test_signal_lineeidt(device_signal_line_edit):
|
||||
@@ -119,3 +133,8 @@ def test_signal_lineeidt(device_signal_line_edit):
|
||||
assert device_signal_line_edit.signals == []
|
||||
device_signal_line_edit.set_device("samx")
|
||||
assert device_signal_line_edit.signals == ["readback", "setpoint", "velocity"]
|
||||
device_signal_line_edit.set_signal("readback")
|
||||
assert device_signal_line_edit.text() == "readback"
|
||||
assert device_signal_line_edit._is_valid_input is True
|
||||
device_signal_line_edit.setText("invalid")
|
||||
assert device_signal_line_edit._is_valid_input is False
|
||||
|
||||
@@ -99,7 +99,7 @@ def test_client_generator_with_black_formatting():
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
333
tests/unit_tests/test_image_roi_tree.py
Normal file
333
tests/unit_tests/test_image_roi_tree.py
Normal file
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_widget(qtbot, mocked_client):
|
||||
"""Create an Image widget with some test data."""
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
# Add a simple test image
|
||||
data = np.zeros((100, 100), dtype=float)
|
||||
data[20:40, 20:40] = 5 # A square region with value 5
|
||||
widget.main_image.set_data(data)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def roi_tree(qtbot, image_widget):
|
||||
"""Create an ROI property tree widget linked to the image widget."""
|
||||
tree = create_widget(qtbot, ROIPropertyTree, image_widget=image_widget)
|
||||
yield tree
|
||||
|
||||
|
||||
def test_initialization(roi_tree, image_widget):
|
||||
"""Test that the widget initializes correctly with the right components."""
|
||||
# Check the widget has the right structure
|
||||
assert roi_tree.image_widget == image_widget
|
||||
assert roi_tree.plot == image_widget.plot_item
|
||||
assert roi_tree.controller == image_widget.roi_controller
|
||||
assert isinstance(roi_tree.roi_items, dict)
|
||||
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
|
||||
|
||||
# Check toolbar actions
|
||||
assert hasattr(roi_tree, "add_rect_action")
|
||||
assert hasattr(roi_tree, "add_circle_action")
|
||||
assert hasattr(roi_tree, "expand_toggle")
|
||||
|
||||
# Check tree view setup
|
||||
assert roi_tree.tree.columnCount() == 3
|
||||
assert roi_tree.tree.headerItem().text(roi_tree.COL_ACTION) == "Actions"
|
||||
assert roi_tree.tree.headerItem().text(roi_tree.COL_ROI) == "ROI"
|
||||
assert roi_tree.tree.headerItem().text(roi_tree.COL_PROPS) == "Properties"
|
||||
|
||||
|
||||
def test_controller_connection(roi_tree, image_widget):
|
||||
"""Test that controller signals/slots are properly connected."""
|
||||
roi = image_widget.add_roi(kind="rect", name="test_roi")
|
||||
|
||||
# Verify that ROI was added to the tree
|
||||
assert roi in roi_tree.roi_items
|
||||
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 1
|
||||
|
||||
# Remove ROI via controller and check that it's removed from the tree
|
||||
image_widget.remove_roi(0)
|
||||
assert roi not in roi_tree.roi_items
|
||||
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 0
|
||||
|
||||
|
||||
def test_expand_collapse_tree(roi_tree, image_widget):
|
||||
"""Test that triggering the expand action expands and collapses all ROI items in the tree."""
|
||||
roi1 = image_widget.add_roi(kind="rect", name="rect1")
|
||||
roi2 = image_widget.add_roi(kind="circle", name="circle1")
|
||||
item1 = roi_tree.roi_items[roi1]
|
||||
item2 = roi_tree.roi_items[roi2]
|
||||
|
||||
# Initially, items should be collapsed
|
||||
assert not item1.isExpanded()
|
||||
assert not item2.isExpanded()
|
||||
|
||||
# Trigger expand
|
||||
roi_tree.expand_toggle.action.trigger()
|
||||
assert item1.isExpanded()
|
||||
assert item2.isExpanded()
|
||||
|
||||
# Trigger collapse
|
||||
roi_tree.expand_toggle.action.trigger()
|
||||
assert not item1.isExpanded()
|
||||
assert not item2.isExpanded()
|
||||
|
||||
|
||||
def test_roi_properties_display(roi_tree, image_widget):
|
||||
"""Test that ROI properties are displayed correctly in the tree."""
|
||||
# Add ROI with specific properties
|
||||
roi = image_widget.add_roi(kind="rect", name="prop_test", line_width=15)
|
||||
roi.line_color = "#FF0000" # bright red
|
||||
|
||||
# Find the tree item
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Check property display
|
||||
assert item.text(roi_tree.COL_ROI) == "prop_test"
|
||||
|
||||
# Find the type item (first child)
|
||||
type_item = item.child(0)
|
||||
assert type_item.text(roi_tree.COL_ROI) == "Type"
|
||||
assert type_item.text(roi_tree.COL_PROPS) == "RectangularROI"
|
||||
|
||||
# Find the width item (second child)
|
||||
width_item = item.child(1)
|
||||
assert width_item.text(roi_tree.COL_ROI) == "Line width"
|
||||
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
|
||||
assert width_spin.value() == 15
|
||||
|
||||
|
||||
def test_roi_name_edit(roi_tree, image_widget, qtbot):
|
||||
"""Test editing the ROI name in the tree."""
|
||||
roi = image_widget.add_roi(kind="rect", name="original_name")
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Edit the name - simulate user editing the item
|
||||
item.setFlags(item.flags() | Qt.ItemIsEditable)
|
||||
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
|
||||
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
|
||||
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
|
||||
qtbot.wait(200)
|
||||
|
||||
# Check the ROI name was updated
|
||||
assert roi.label == "new_name"
|
||||
assert item.text(roi_tree.COL_ROI) == "new_name"
|
||||
|
||||
|
||||
def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||
"""Test editing ROI line width via spin box."""
|
||||
roi = image_widget.add_roi(kind="rect", name="width_test", line_width=5)
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Find the width spin box
|
||||
width_item = item.child(1) # Second child item (index 1)
|
||||
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
|
||||
|
||||
# Change the width
|
||||
width_spin.setValue(25)
|
||||
qtbot.wait(200)
|
||||
# Check the ROI width was updated
|
||||
assert roi.line_width == 25
|
||||
|
||||
|
||||
def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||
"""Test that the delete button correctly removes the ROI."""
|
||||
roi = image_widget.add_roi(kind="rect", name="to_delete")
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Get the delete button
|
||||
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
|
||||
|
||||
# Click the delete button
|
||||
del_btn.click()
|
||||
qtbot.wait(200)
|
||||
|
||||
# Verify ROI was removed
|
||||
assert roi not in roi_tree.roi_items
|
||||
assert roi not in image_widget.roi_controller.rois
|
||||
|
||||
|
||||
def test_roi_color_change_from_roi(roi_tree, image_widget):
|
||||
"""Test that changing the ROI color updates the tree display."""
|
||||
roi = image_widget.add_roi(kind="rect", name="color_test")
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Change the ROI color directly
|
||||
roi.line_color = "#00FF00" # bright green
|
||||
|
||||
# Check that the color button was updated
|
||||
color_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_PROPS)
|
||||
assert color_btn.color == "#00FF00"
|
||||
|
||||
|
||||
def test_colormap_change(roi_tree, image_widget):
|
||||
"""Test changing the colormap affects ROI colors."""
|
||||
# Add multiple ROIs
|
||||
roi1 = image_widget.add_roi(kind="rect", name="r1")
|
||||
roi2 = image_widget.add_roi(kind="circle", name="c1")
|
||||
|
||||
# Store original colors
|
||||
orig_colors = [roi1.line_color, roi2.line_color]
|
||||
|
||||
# Change colormap to "plasma" from the color map widget
|
||||
roi_tree.cmap.colormap = "plasma"
|
||||
|
||||
# Colors should have changed
|
||||
new_colors = [roi1.line_color, roi2.line_color]
|
||||
assert new_colors != orig_colors
|
||||
|
||||
|
||||
def test_coordinates_update(roi_tree, image_widget):
|
||||
"""Test that coordinates update when ROI is moved."""
|
||||
# Add a rectangular ROI
|
||||
roi = image_widget.add_roi(kind="rect", name="moving_roi", pos=(10, 10), size=(20, 20))
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Find coordinate items (type and width are 0 and 1, coordinates start at 2)
|
||||
coordinate_items = [item.child(i) for i in range(2, item.childCount())]
|
||||
|
||||
# Store initial coordinates
|
||||
initial_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
|
||||
|
||||
# Move the ROI
|
||||
roi.setPos(50, 50)
|
||||
|
||||
# Check that coordinates were updated
|
||||
new_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
|
||||
assert new_coords != initial_coords
|
||||
|
||||
|
||||
def test_draw_mode_toggle(roi_tree, qtbot):
|
||||
"""Test toggling draw modes."""
|
||||
# Initially no draw mode
|
||||
assert roi_tree._roi_draw_mode is None
|
||||
|
||||
# Toggle rect mode on
|
||||
roi_tree.add_rect_action.action.toggle()
|
||||
assert roi_tree._roi_draw_mode == "rect"
|
||||
assert roi_tree.add_rect_action.action.isChecked()
|
||||
assert not roi_tree.add_circle_action.action.isChecked()
|
||||
|
||||
# Toggle circle mode on (should turn off rect mode)
|
||||
roi_tree.add_circle_action.action.toggle()
|
||||
qtbot.wait(200)
|
||||
assert roi_tree._roi_draw_mode == "circle"
|
||||
assert not roi_tree.add_rect_action.action.isChecked()
|
||||
assert roi_tree.add_circle_action.action.isChecked()
|
||||
|
||||
# Toggle circle mode off
|
||||
roi_tree.add_circle_action.action.toggle()
|
||||
assert roi_tree._roi_draw_mode is None
|
||||
assert not roi_tree.add_rect_action.action.isChecked()
|
||||
assert not roi_tree.add_circle_action.action.isChecked()
|
||||
|
||||
|
||||
def test_add_roi_from_toolbar(qtbot, mocked_client):
|
||||
"""Test creating ROIs using the toolbar and mouse interactions."""
|
||||
# Create Image widget with ROI tree
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
data = np.zeros((100, 100), dtype=float)
|
||||
widget.main_image.set_data(data)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
roi_tree = create_widget(qtbot, ROIPropertyTree, image_widget=widget)
|
||||
|
||||
# Get initial ROI count
|
||||
initial_roi_count = len(widget.roi_controller.rois)
|
||||
|
||||
# Test rectangle ROI creation
|
||||
# 1. Activate rectangle drawing mode
|
||||
roi_tree.add_rect_action.action.setChecked(True)
|
||||
assert roi_tree._roi_draw_mode == "rect"
|
||||
|
||||
# Get plot widget and view
|
||||
plot_item = widget.plot_item
|
||||
view = plot_item.vb.scene().views()[0]
|
||||
qtbot.waitExposed(view)
|
||||
|
||||
# Define start and end points for the ROI (in view coordinates)
|
||||
start_pos = QPointF(20, 20)
|
||||
end_pos = QPointF(60, 60)
|
||||
|
||||
# Map view coordinates to scene coordinates
|
||||
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||
|
||||
# Map scene coordinates to widget coordinates
|
||||
start_pos_widget = view.mapFromScene(start_pos_scene)
|
||||
end_pos_widget = view.mapFromScene(end_pos_scene)
|
||||
|
||||
# Using qtbot to simulate mouse actions
|
||||
# First click to start drawing
|
||||
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
|
||||
|
||||
# Then move to end position
|
||||
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
|
||||
|
||||
# Finally release to complete the ROI
|
||||
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
|
||||
|
||||
# Wait for signals to process
|
||||
qtbot.wait(200)
|
||||
|
||||
# Check that a new ROI was created
|
||||
assert len(widget.roi_controller.rois) == initial_roi_count + 1
|
||||
|
||||
# Get the newly created ROI
|
||||
new_roi = widget.roi_controller.rois[-1]
|
||||
|
||||
# Verify it's a rectangular ROI
|
||||
assert isinstance(new_roi, RectangularROI)
|
||||
|
||||
# Test circle ROI creation
|
||||
# Reset ROI draw mode
|
||||
roi_tree.add_rect_action.action.setChecked(False)
|
||||
roi_tree.add_circle_action.action.setChecked(True)
|
||||
assert roi_tree._roi_draw_mode == "circle"
|
||||
|
||||
# Define new positions for circle ROI
|
||||
start_pos = QPointF(30, 30)
|
||||
end_pos = QPointF(50, 50)
|
||||
|
||||
# Map view coordinates to scene coordinates
|
||||
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||
|
||||
# Map scene coordinates to widget coordinates
|
||||
start_pos_widget = view.mapFromScene(start_pos_scene)
|
||||
end_pos_widget = view.mapFromScene(end_pos_scene)
|
||||
|
||||
# Using qtbot to simulate mouse actions
|
||||
# First click to start drawing
|
||||
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
|
||||
|
||||
# Then move to end position
|
||||
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
|
||||
|
||||
# Finally release to complete the ROI
|
||||
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
|
||||
|
||||
# Wait for signals to process
|
||||
qtbot.wait(200)
|
||||
|
||||
# Check that a new ROI was created
|
||||
assert len(widget.roi_controller.rois) == initial_roi_count + 2
|
||||
|
||||
# Get the newly created ROI
|
||||
new_roi = widget.roi_controller.rois[-1]
|
||||
|
||||
# Verify it's a circle ROI
|
||||
assert isinstance(new_roi, CircularROI)
|
||||
207
tests/unit_tests/test_image_rois.py
Normal file
207
tests/unit_tests/test_image_rois.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
from tests.unit_tests.conftest import create_widget
|
||||
|
||||
|
||||
@pytest.fixture(params=["rect", "circle"])
|
||||
def bec_image_widget_with_roi(qtbot, request, mocked_client):
|
||||
"""Return (widget, roi, shape_label) for each ROI class."""
|
||||
|
||||
roi_type: Literal["rect", "circle"] = request.param
|
||||
|
||||
# Build an Image widget with a trivial 100×100 zeros array
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
data = np.zeros((100, 100), dtype=float)
|
||||
data[20:40, 20:40] = 5 # content assertion for roi to check
|
||||
widget.main_image.set_data(data)
|
||||
|
||||
# Add a single ROI via the public API
|
||||
roi = widget.add_roi(kind=roi_type)
|
||||
|
||||
yield widget, roi, roi_type
|
||||
|
||||
|
||||
def test_default_properties(bec_image_widget_with_roi):
|
||||
"""Label, width, type sanity‑check."""
|
||||
|
||||
_widget, roi, roi_type = bec_image_widget_with_roi
|
||||
|
||||
assert roi.label.startswith("ROI")
|
||||
|
||||
assert roi.line_width == 5
|
||||
|
||||
# concrete subclass type
|
||||
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
|
||||
|
||||
|
||||
def test_coordinate_structures(bec_image_widget_with_roi):
|
||||
"""Typed vs untyped coordinate structures are consistent."""
|
||||
|
||||
_widget, roi, _ = bec_image_widget_with_roi
|
||||
|
||||
raw = roi.get_coordinates(typed=False)
|
||||
typed = roi.get_coordinates(typed=True)
|
||||
|
||||
# untyped is always a tuple
|
||||
assert isinstance(raw, tuple)
|
||||
|
||||
# typed is always a dict and has same number of scalars as raw flattens to
|
||||
assert isinstance(typed, dict)
|
||||
assert sum(isinstance(v, (tuple, list)) and len(v) or 1 for v in typed.values()) == len(
|
||||
np.ravel(raw)
|
||||
)
|
||||
|
||||
|
||||
def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
|
||||
"""Pixels reported by get_data_from_image have non‑zero size and match ROI extents."""
|
||||
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
|
||||
pixels = roi.get_data_from_image() # auto‑detect ImageItem
|
||||
|
||||
assert pixels.size > 0 # ROI covers at least one pixel
|
||||
|
||||
# For rectangular ROI: pixel bounding box equals coordinate bbox
|
||||
if isinstance(roi, RectangularROI):
|
||||
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
|
||||
# ensure ints inside image shape
|
||||
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
|
||||
expected = widget.main_image.image[y0:y1, x0:x1]
|
||||
assert pixels.shape == expected.shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0])
|
||||
def test_controller_remove_by_index(bec_image_widget_with_roi, index):
|
||||
"""Image.remove_roi(index) removes the graphics item and updates controller."""
|
||||
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
controller: ROIController = widget.roi_controller
|
||||
|
||||
assert controller.rois # non‑empty before
|
||||
|
||||
widget.remove_roi(index)
|
||||
|
||||
# ROI list now empty and item no longer in scene
|
||||
assert not controller.rois
|
||||
assert roi not in widget.plot_item.items
|
||||
|
||||
|
||||
def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
|
||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
# add two of each ROI type
|
||||
for _kind in ("rect", "circle"):
|
||||
widget.add_roi(kind=_kind)
|
||||
widget.add_roi(kind=_kind)
|
||||
|
||||
colors = [r.line_color for r in widget.roi_controller.rois]
|
||||
assert len(colors) == len(set(colors)), "Colors must be unique per ROI"
|
||||
|
||||
|
||||
def test_roi_label_and_signals(bec_image_widget_with_roi):
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
changed = []
|
||||
roi.nameChanged.connect(lambda name: changed.append(name))
|
||||
roi.label = "new_label"
|
||||
assert roi.label == "new_label"
|
||||
assert changed and changed[0] == "new_label"
|
||||
|
||||
|
||||
def test_roi_line_color_and_width(bec_image_widget_with_roi):
|
||||
_widget, roi, _ = bec_image_widget_with_roi
|
||||
changed = []
|
||||
roi.penChanged.connect(lambda: changed.append(True))
|
||||
roi.line_color = "#123456"
|
||||
assert roi.line_color == "#123456"
|
||||
roi.line_width = 5
|
||||
assert roi.line_width == 5
|
||||
assert changed # penChanged should have been emitted
|
||||
|
||||
|
||||
def test_roi_controller_add_remove_multiple(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
controller = widget.roi_controller
|
||||
r1 = widget.add_roi(kind="rect", name="r1")
|
||||
r2 = widget.add_roi(kind="circle", name="c1")
|
||||
assert r1 in controller.rois and r2 in controller.rois
|
||||
widget.remove_roi("r1")
|
||||
assert r1 not in controller.rois and r2 in controller.rois
|
||||
widget.remove_roi("c1")
|
||||
assert not controller.rois
|
||||
|
||||
|
||||
def test_roi_controller_colormap_changes(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
controller = widget.roi_controller
|
||||
widget.add_roi(kind="rect")
|
||||
widget.add_roi(kind="circle")
|
||||
old_colors = [r.line_color for r in controller.rois]
|
||||
controller.colormap = "plasma"
|
||||
new_colors = [r.line_color for r in controller.rois]
|
||||
assert old_colors != new_colors
|
||||
assert all(isinstance(c, str) for c in new_colors)
|
||||
|
||||
|
||||
def test_roi_controller_clear(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
widget.add_roi(kind="rect")
|
||||
widget.add_roi(kind="circle")
|
||||
controller = widget.roi_controller
|
||||
controller.clear()
|
||||
assert not controller.rois
|
||||
|
||||
|
||||
def test_roi_get_data_from_image_no_image(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
roi = widget.add_roi(kind="rect")
|
||||
# Remove all images from scene
|
||||
for item in list(widget.plot_item.items):
|
||||
if hasattr(item, "image"):
|
||||
widget.plot_item.removeItem(item)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
roi.get_data_from_image()
|
||||
|
||||
|
||||
def test_roi_remove_cleans_up(bec_image_widget_with_roi):
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
roi.remove()
|
||||
assert roi not in widget.roi_controller.rois
|
||||
assert roi not in widget.plot_item.items
|
||||
|
||||
|
||||
def test_roi_controller_get_roi_methods(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||
r1 = widget.add_roi(kind="rect", name="findme")
|
||||
r2 = widget.add_roi(kind="circle")
|
||||
controller = widget.roi_controller
|
||||
assert controller.get_roi_by_name("findme") == r1
|
||||
assert controller.get_roi(1) == r2
|
||||
assert controller.get_roi(99) is None
|
||||
assert controller.get_roi_by_name("notfound") is None
|
||||
|
||||
|
||||
def test_roi_set_position(bec_image_widget_with_roi):
|
||||
"""Test that set_position updates the ROI position and coordinates."""
|
||||
widget, roi, _ = bec_image_widget_with_roi
|
||||
# Save original coordinates
|
||||
orig_coords = roi.get_coordinates(typed=False)
|
||||
# Move ROI by a known offset
|
||||
roi.set_position(10, 15)
|
||||
new_coords = roi.get_coordinates(typed=False)
|
||||
# The new position should reflect the set_position call
|
||||
assert new_coords != orig_coords
|
||||
# The first coordinate should match the new position
|
||||
if hasattr(roi, "pos"):
|
||||
pos = roi.pos()
|
||||
assert int(pos.x()) == 10
|
||||
assert int(pos.y()) == 15
|
||||
@@ -168,7 +168,7 @@ def test_image_data_update_1d(qtbot, mocked_client):
|
||||
|
||||
def test_toolbar_actions_presence(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
assert "autorange_image" in bec_image_view.toolbar.bundles["roi"]
|
||||
assert "autorange_image" in bec_image_view.toolbar.widgets
|
||||
assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"]
|
||||
assert "processing" in bec_image_view.toolbar.bundles
|
||||
assert "selection" in bec_image_view.toolbar.bundles
|
||||
@@ -329,3 +329,157 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
|
||||
assert bec_image_view.main_image.log is False
|
||||
assert bec_image_view.transpose is False
|
||||
assert bec_image_view.main_image.transpose is False
|
||||
|
||||
|
||||
def test_roi_add_remove_and_properties(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
# Add ROIs
|
||||
rect = view.add_roi(kind="rect", name="rect_roi", line_width=7)
|
||||
circ = view.add_roi(kind="circle", name="circ_roi", line_width=5)
|
||||
assert rect in view.roi_controller.rois
|
||||
assert circ in view.roi_controller.rois
|
||||
assert rect.label == "rect_roi"
|
||||
assert circ.label == "circ_roi"
|
||||
assert rect.line_width == 7
|
||||
assert circ.line_width == 5
|
||||
# Change properties
|
||||
rect.label = "rect_roi2"
|
||||
circ.line_color = "#ff0000"
|
||||
assert rect.label == "rect_roi2"
|
||||
assert circ.line_color == "#ff0000"
|
||||
# Remove by name
|
||||
view.remove_roi("rect_roi2")
|
||||
assert rect not in view.roi_controller.rois
|
||||
# Remove by index
|
||||
view.remove_roi(0)
|
||||
assert not view.roi_controller.rois
|
||||
|
||||
|
||||
def test_roi_controller_palette_signal(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
controller = view.roi_controller
|
||||
changed = []
|
||||
controller.paletteChanged.connect(lambda cmap: changed.append(cmap))
|
||||
view.add_roi(kind="rect")
|
||||
controller.colormap = "plasma"
|
||||
assert changed and changed[0] == "plasma"
|
||||
|
||||
|
||||
def test_roi_controller_clear_and_get_methods(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
r1 = view.add_roi(kind="rect", name="r1")
|
||||
r2 = view.add_roi(kind="circle", name="c1")
|
||||
controller = view.roi_controller
|
||||
assert controller.get_roi_by_name("r1") == r1
|
||||
assert controller.get_roi(1) == r2
|
||||
controller.clear()
|
||||
assert not controller.rois
|
||||
|
||||
|
||||
def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
roi = view.add_roi(kind="rect")
|
||||
# Remove all images from scene
|
||||
for item in list(view.plot_item.items):
|
||||
if hasattr(item, "image"):
|
||||
view.plot_item.removeItem(item)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
roi.get_data_from_image()
|
||||
|
||||
|
||||
##################################################
|
||||
# Settings and popups
|
||||
##################################################
|
||||
def test_show_roi_manager_popup(qtbot, mocked_client):
|
||||
"""
|
||||
Verify that the ROI-manager dialog opens and closes correctly,
|
||||
and that the matching toolbar icon stays in sync.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client, popups=True)
|
||||
|
||||
# ROI-manager toggle is exposed via the toolbar.
|
||||
assert "roi_mgr" in view.toolbar.widgets
|
||||
roi_action = view.toolbar.widgets["roi_mgr"].action
|
||||
assert roi_action.isChecked() is False, "Should start unchecked"
|
||||
|
||||
# Open the popup.
|
||||
view.show_roi_manager_popup()
|
||||
|
||||
assert view.roi_manager_dialog is not None
|
||||
assert view.roi_manager_dialog.isVisible()
|
||||
assert roi_action.isChecked() is True, "Icon should toggle on"
|
||||
|
||||
# Close again.
|
||||
view.roi_manager_dialog.close()
|
||||
assert view.roi_manager_dialog is None
|
||||
assert roi_action.isChecked() is False, "Icon should toggle off"
|
||||
|
||||
|
||||
###################################
|
||||
# ROI Plots & Crosshair Switch
|
||||
###################################
|
||||
|
||||
|
||||
def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
|
||||
"""
|
||||
Verify that enabling the ROI‑crosshair shows ROI panels and disabling hides them.
|
||||
"""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
|
||||
|
||||
# Initially panels should be hidden
|
||||
assert bec_image_view.side_panel_x.panel_height == 0
|
||||
assert bec_image_view.side_panel_y.panel_width == 0
|
||||
|
||||
# Enable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels must be visible
|
||||
assert bec_image_view.side_panel_x.panel_height > 0
|
||||
assert bec_image_view.side_panel_y.panel_width > 0
|
||||
|
||||
# Disable ROI crosshair
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(500)
|
||||
|
||||
# Panels hidden again
|
||||
assert bec_image_view.side_panel_x.panel_height == 0
|
||||
assert bec_image_view.side_panel_y.panel_width == 0
|
||||
|
||||
|
||||
def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
"""
|
||||
Check that ROI plots receive correct slice data from the 2D image.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
# Provide deterministic 2D data
|
||||
test_data = np.arange(25).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
|
||||
# Activate ROI crosshair
|
||||
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
|
||||
# Simulate crosshair at row 2, col 3
|
||||
bec_image_view.update_image_slices((0, 2, 3))
|
||||
|
||||
# Extract plotted data
|
||||
x_items = bec_image_view.x_roi.plot_item.listDataItems()
|
||||
y_items = bec_image_view.y_roi.plot_item.listDataItems()
|
||||
|
||||
assert len(x_items) == 1
|
||||
assert len(y_items) == 1
|
||||
|
||||
# Vertical slice (column)
|
||||
_, v_slice = x_items[0].getData()
|
||||
np.testing.assert_array_equal(v_slice, test_data[:, 3])
|
||||
|
||||
# Horizontal slice (row)
|
||||
h_slice, _ = y_items[0].getData()
|
||||
np.testing.assert_array_equal(h_slice, test_data[2])
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.applications.launch_window import LaunchWindow
|
||||
@@ -153,3 +154,32 @@ def test_launch_window_closes(bec_launch_window, connections, close_called):
|
||||
mock_hide.assert_called_once()
|
||||
close_event.accept.assert_not_called()
|
||||
close_event.ignore.assert_called_once()
|
||||
|
||||
|
||||
def test_main_label_fits_tile_width(bec_launch_window, qtbot):
|
||||
"""
|
||||
Every tile’s main label must render in a single line and its text
|
||||
width must not exceed the usable width of the tile.
|
||||
"""
|
||||
for name, tile in bec_launch_window.tiles.items():
|
||||
label = tile.main_label
|
||||
qtbot.waitUntil(lambda: label.isVisible())
|
||||
metrics = QFontMetrics(label.font())
|
||||
text_width = metrics.horizontalAdvance(label.text())
|
||||
content_width = (
|
||||
tile.tile_size[0]
|
||||
- tile.layout.contentsMargins().left()
|
||||
- tile.layout.contentsMargins().right()
|
||||
)
|
||||
assert text_width <= content_width, f"{name} main label exceeds tile width"
|
||||
# _fit_label_to_width disables wrapping, so confirm that:
|
||||
assert not label.wordWrap(), f"{name} main label is wrapped"
|
||||
|
||||
|
||||
def test_main_label_point_size_uniform(bec_launch_window):
|
||||
"""
|
||||
The launcher should unify all main-label font sizes to the smallest
|
||||
needed size, so every tile shares the same point size.
|
||||
"""
|
||||
point_sizes = {tile.main_label.font().pointSize() for tile in bec_launch_window.tiles.values()}
|
||||
assert len(point_sizes) == 1, f"Non-uniform main-label point sizes: {point_sizes}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Optional
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -8,7 +9,8 @@ from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutM
|
||||
|
||||
|
||||
class MockWidgetHandler:
|
||||
def create_widget(self, widget_type: str) -> Optional[QWidget]:
|
||||
|
||||
def create_widget(self, widget_type: str) -> QWidget | None:
|
||||
if widget_type == "ButtonWidget":
|
||||
return QPushButton()
|
||||
elif widget_type == "LabelWidget":
|
||||
@@ -225,13 +227,11 @@ def test_add_widget_overlap_with_span(layout_manager):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"position, btn3_coords",
|
||||
[("left", (1, 0)), ("right", (1, 2)), ("top", (0, 1)), ("bottom", (2, 1))],
|
||||
"position, expected_position",
|
||||
[("left", "left"), ("right", "right"), ("top", "top"), ("bottom", "bottom")],
|
||||
)
|
||||
def test_add_widget_relative(layout_manager, position, btn3_coords):
|
||||
def test_add_widget_relative(layout_manager, position, expected_position):
|
||||
"""Test adding a widget relative to an existing widget using parameterized data."""
|
||||
expected_row, expected_col = btn3_coords
|
||||
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
btn3 = QPushButton("Button 3")
|
||||
@@ -241,9 +241,28 @@ def test_add_widget_relative(layout_manager, position, btn3_coords):
|
||||
|
||||
layout_manager.add_widget_relative(btn3, reference_widget=btn2, position=position)
|
||||
|
||||
assert layout_manager.get_widget(0, 0) == btn1
|
||||
assert layout_manager.get_widget(1, 1) == btn2
|
||||
assert layout_manager.get_widget(expected_row, expected_col) == btn3
|
||||
# Get the actual positions of the widgets
|
||||
btn1_pos = layout_manager.widget_positions[btn1]
|
||||
btn2_pos = layout_manager.widget_positions[btn2]
|
||||
btn3_pos = layout_manager.widget_positions[btn3]
|
||||
|
||||
# Check that btn1 and btn2 are still in the layout
|
||||
assert btn1 in layout_manager.widget_positions
|
||||
assert btn2 in layout_manager.widget_positions
|
||||
|
||||
# Check that btn3 is positioned correctly relative to btn2
|
||||
if expected_position == "left":
|
||||
assert btn3_pos[1] < btn2_pos[1] # btn3's column < btn2's column
|
||||
assert btn3_pos[0] == btn2_pos[0] # same row
|
||||
elif expected_position == "right":
|
||||
assert btn3_pos[1] > btn2_pos[1] # btn3's column > btn2's column
|
||||
assert btn3_pos[0] == btn2_pos[0] # same row
|
||||
elif expected_position == "top":
|
||||
assert btn3_pos[0] < btn2_pos[0] # btn3's row < btn2's row
|
||||
assert btn3_pos[1] == btn2_pos[1] # same column
|
||||
elif expected_position == "bottom":
|
||||
assert btn3_pos[0] > btn2_pos[0] # btn3's row > btn2's row
|
||||
assert btn3_pos[1] == btn2_pos[1] # same column
|
||||
|
||||
|
||||
def test_add_widget_relative_invalid_position(layout_manager):
|
||||
@@ -366,3 +385,74 @@ def test_shift_all_widgets_up_at_top_row(layout_manager):
|
||||
layout_manager.shift_all_widgets(direction="up")
|
||||
|
||||
assert "Shifting widgets out of grid boundaries." in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_id, position, shift_direction, additional_assertions",
|
||||
[
|
||||
(
|
||||
"from_left",
|
||||
"left",
|
||||
"right",
|
||||
[
|
||||
# Additional assertions for the left test case
|
||||
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn1_new_pos[1]
|
||||
> btn1_pos[1], # column shifted right
|
||||
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn2_new_pos[1]
|
||||
> btn2_pos[1], # column shifted right
|
||||
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn3_pos[1]
|
||||
< btn2_new_pos[1], # btn3 is to the left of btn2
|
||||
],
|
||||
),
|
||||
(
|
||||
"from_right",
|
||||
"right",
|
||||
"right",
|
||||
[
|
||||
# Additional assertions for the right test case
|
||||
lambda btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos: btn3_pos[1]
|
||||
> btn2_new_pos[1] # btn3 is to the right of btn2
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_column_shift_when_adding_widget(
|
||||
layout_manager, test_id, position, shift_direction, additional_assertions
|
||||
):
|
||||
"""Test that adding a widget to a column of widgets shifts the entire column appropriately."""
|
||||
# Create a column of widgets
|
||||
btn1 = QPushButton("Button 1")
|
||||
btn2 = QPushButton("Button 2")
|
||||
|
||||
# Add btn1 at position (0, 1)
|
||||
layout_manager.add_widget(btn1, row=0, col=1)
|
||||
|
||||
# Add btn2 below btn1
|
||||
layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="bottom")
|
||||
|
||||
# Get the positions after initial setup
|
||||
btn1_pos = layout_manager.widget_positions[btn1]
|
||||
btn2_pos = layout_manager.widget_positions[btn2]
|
||||
|
||||
# Verify btn2 is below btn1 (same column)
|
||||
assert btn1_pos[0] < btn2_pos[0] # btn2's row > btn1's row
|
||||
assert btn1_pos[1] == btn2_pos[1] # same column
|
||||
|
||||
# Add a new button relative to btn2 with the specified position and shift_direction
|
||||
btn3 = QPushButton("Button 3")
|
||||
layout_manager.add_widget_relative(
|
||||
btn3, reference_widget=btn2, position=position, shift_direction=shift_direction
|
||||
)
|
||||
|
||||
# Get the updated positions
|
||||
btn1_new_pos = layout_manager.widget_positions[btn1]
|
||||
btn2_new_pos = layout_manager.widget_positions[btn2]
|
||||
btn3_pos = layout_manager.widget_positions[btn3]
|
||||
|
||||
# Common assertions for both test cases
|
||||
assert btn1_new_pos[1] == btn2_new_pos[1] # btn1 and btn2 still in same column
|
||||
assert btn3_pos[0] == btn2_new_pos[0] # btn3 is in the same row as btn2
|
||||
|
||||
# Run additional assertions specific to each test case
|
||||
for assertion in additional_assertions:
|
||||
assertion(btn1_pos, btn1_new_pos, btn2_pos, btn2_new_pos, btn3_pos)
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections import deque
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import LogMessage
|
||||
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
from qtpy.QtCore import QDateTime
|
||||
|
||||
from bec_widgets.widgets.utility.logpanel._util import (
|
||||
log_time,
|
||||
@@ -136,3 +137,31 @@ def test_timestamp_filter(log_panel: LogPanel):
|
||||
assert not filter_(TEST_LOG_MESSAGES[0])
|
||||
assert filter_(TEST_LOG_MESSAGES[1])
|
||||
assert not filter_(TEST_LOG_MESSAGES[2])
|
||||
|
||||
|
||||
def test_error_handling_in_callback(log_panel: LogPanel):
|
||||
log_panel._log_manager.new_message = MagicMock()
|
||||
|
||||
cbs = (lambda: log_panel._log_manager._process_incoming_log_msg, {})
|
||||
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
|
||||
# generally errors should be logged
|
||||
log_panel._log_manager.new_message.emit = MagicMock(
|
||||
side_effect=ValueError("Something went wrong")
|
||||
)
|
||||
log_panel.client.connector._handle_message(
|
||||
msg=StreamMessage(
|
||||
msg={"data": LogMessage(log_type="debug", log_msg="message")}, callbacks=[cbs]
|
||||
)
|
||||
)
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
# this specific error should be ignored and not relogged
|
||||
log_panel._log_manager.new_message.emit = MagicMock(
|
||||
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
|
||||
)
|
||||
log_panel.client.connector._handle_message(
|
||||
msg=StreamMessage(
|
||||
msg={"data": LogMessage(log_type="debug", log_msg="message")}, callbacks=[cbs]
|
||||
)
|
||||
)
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
@@ -518,3 +518,156 @@ def test_long_pressbutton(toolbar_fixture, dummy_widget, switchable_toolbar_acti
|
||||
|
||||
# Verify that fake_showMenu() was called.
|
||||
assert call_flag, "Long press did not trigger showMenu() as expected."
|
||||
|
||||
|
||||
# Additional tests for action/bundle removal
|
||||
def test_remove_standalone_action(toolbar_fixture, icon_action, dummy_widget):
|
||||
"""
|
||||
Ensure that a standalone action is fully removed and no longer accessible.
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
# Add the action and check it is present
|
||||
toolbar.add_action("icon_action", icon_action, dummy_widget)
|
||||
assert "icon_action" in toolbar.widgets
|
||||
assert icon_action.action in toolbar.actions()
|
||||
|
||||
# Now remove it
|
||||
toolbar.remove_action("icon_action")
|
||||
|
||||
# Action bookkeeping
|
||||
assert "icon_action" not in toolbar.widgets
|
||||
# QAction list
|
||||
assert icon_action.action not in toolbar.actions()
|
||||
# Attempting to hide / show it should raise
|
||||
with pytest.raises(ValueError):
|
||||
toolbar.hide_action("icon_action")
|
||||
with pytest.raises(ValueError):
|
||||
toolbar.show_action("icon_action")
|
||||
|
||||
|
||||
def test_remove_action_from_bundle(
|
||||
toolbar_fixture, dummy_widget, icon_action, material_icon_action
|
||||
):
|
||||
"""
|
||||
Remove a single action that is part of a bundle and verify clean‑up.
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
bundle = ToolbarBundle(
|
||||
bundle_id="test_bundle",
|
||||
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
|
||||
)
|
||||
toolbar.add_bundle(bundle, dummy_widget)
|
||||
|
||||
# Initial assertions
|
||||
assert "test_bundle" in toolbar.bundles
|
||||
assert "icon_action" in toolbar.widgets
|
||||
assert "material_action" in toolbar.widgets
|
||||
|
||||
# Remove one action from the bundle
|
||||
toolbar.remove_action("icon_action")
|
||||
|
||||
# icon_action should be fully gone
|
||||
assert "icon_action" not in toolbar.widgets
|
||||
assert icon_action.action not in toolbar.actions()
|
||||
# Bundle tracking should be updated
|
||||
assert "icon_action" not in toolbar.bundles["test_bundle"]
|
||||
# The other action must still exist
|
||||
assert "material_action" in toolbar.widgets
|
||||
assert material_icon_action.action in toolbar.actions()
|
||||
|
||||
|
||||
def test_remove_last_action_from_bundle_removes_bundle(toolbar_fixture, dummy_widget, icon_action):
|
||||
"""
|
||||
Removing the final action from a bundle should delete the bundle entry itself.
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
bundle = ToolbarBundle(bundle_id="single_action_bundle", actions=[("only_action", icon_action)])
|
||||
toolbar.add_bundle(bundle, dummy_widget)
|
||||
|
||||
# Sanity check
|
||||
assert "single_action_bundle" in toolbar.bundles
|
||||
assert "only_action" in toolbar.widgets
|
||||
|
||||
# Remove the sole action
|
||||
toolbar.remove_action("only_action")
|
||||
|
||||
# Bundle should be gone
|
||||
assert "single_action_bundle" not in toolbar.bundles
|
||||
# QAction removed
|
||||
assert icon_action.action not in toolbar.actions()
|
||||
|
||||
|
||||
def test_remove_entire_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
|
||||
"""
|
||||
Ensure that removing a bundle deletes all its actions and separators.
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
bundle = ToolbarBundle(
|
||||
bundle_id="to_remove",
|
||||
actions=[("icon_action", icon_action), ("material_action", material_icon_action)],
|
||||
)
|
||||
toolbar.add_bundle(bundle, dummy_widget)
|
||||
|
||||
# Confirm bundle presence
|
||||
assert "to_remove" in toolbar.bundles
|
||||
|
||||
# Remove the whole bundle
|
||||
toolbar.remove_bundle("to_remove")
|
||||
|
||||
# Bundle mapping gone
|
||||
assert "to_remove" not in toolbar.bundles
|
||||
# All actions gone
|
||||
for aid, act in [("icon_action", icon_action), ("material_action", material_icon_action)]:
|
||||
assert aid not in toolbar.widgets
|
||||
assert act.action not in toolbar.actions()
|
||||
|
||||
|
||||
def test_trigger_removed_action_raises(toolbar_fixture, icon_action, dummy_widget, qtbot):
|
||||
"""
|
||||
Add an action, connect a mock slot, then remove the action and verify that
|
||||
attempting to trigger it afterwards raises RuntimeError (since the underlying
|
||||
QAction has been deleted).
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
|
||||
# Add the action and connect a mock slot
|
||||
toolbar.add_action("icon_action", icon_action, dummy_widget)
|
||||
called = []
|
||||
|
||||
def mock_slot():
|
||||
called.append(True)
|
||||
|
||||
icon_action.action.triggered.connect(mock_slot)
|
||||
|
||||
# Trigger once to confirm connection works
|
||||
icon_action.action.trigger()
|
||||
assert called == [True]
|
||||
|
||||
# Now remove the action
|
||||
toolbar.remove_action("icon_action")
|
||||
# Allow deleteLater event to process
|
||||
qtbot.wait(50)
|
||||
|
||||
# The underlying C++ object should be deleted; triggering should raise
|
||||
with pytest.raises(RuntimeError):
|
||||
icon_action.action.trigger()
|
||||
|
||||
|
||||
def test_remove_nonexistent_action(toolbar_fixture):
|
||||
"""
|
||||
Attempting to remove an action that does not exist should raise ValueError.
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
toolbar.remove_action("nonexistent_action")
|
||||
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
|
||||
|
||||
|
||||
def test_remove_nonexistent_bundle(toolbar_fixture):
|
||||
"""
|
||||
Attempting to remove a bundle that does not exist should raise ValueError.
|
||||
"""
|
||||
toolbar = toolbar_fixture
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
toolbar.remove_bundle("nonexistent_bundle")
|
||||
assert "Bundle 'nonexistent_bundle' does not exist." in str(excinfo.value)
|
||||
|
||||
@@ -51,10 +51,11 @@ def test_set_x_label_emits_signal(qtbot, mocked_client):
|
||||
"""
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
with qtbot.waitSignal(pb.property_changed, timeout=500) as signal:
|
||||
pb.x_label = "Voltage (V)"
|
||||
assert signal.args == ["x_label", "Voltage (V)"]
|
||||
assert pb.x_label == "Voltage (V)"
|
||||
assert pb.plot_item.getAxis("bottom").labelText == "Voltage (V)"
|
||||
pb.x_label = "Voltage"
|
||||
assert signal.args == ["x_label", "Voltage"]
|
||||
assert pb.x_label == "Voltage"
|
||||
pb.x_label_units = "V"
|
||||
assert pb.plot_item.getAxis("bottom").labelText == "Voltage [V]"
|
||||
|
||||
|
||||
def test_set_y_label_emits_signal(qtbot, mocked_client):
|
||||
@@ -63,10 +64,11 @@ def test_set_y_label_emits_signal(qtbot, mocked_client):
|
||||
"""
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
with qtbot.waitSignal(pb.property_changed, timeout=500) as signal:
|
||||
pb.y_label = "Current (A)"
|
||||
assert signal.args == ["y_label", "Current (A)"]
|
||||
assert pb.y_label == "Current (A)"
|
||||
assert pb.plot_item.getAxis("left").labelText == "Current (A)"
|
||||
pb.y_label = "Current"
|
||||
assert signal.args == ["y_label", "Current"]
|
||||
assert pb.y_label == "Current"
|
||||
pb.y_label_units = "A"
|
||||
assert pb.plot_item.getAxis("left").labelText == "Current [A]"
|
||||
|
||||
|
||||
def test_set_x_min_max(qtbot, mocked_client):
|
||||
|
||||
@@ -7,6 +7,7 @@ from bec_widgets.cli import client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
|
||||
|
||||
@@ -21,7 +22,12 @@ class _TestPluginWidget(BECWidget): ...
|
||||
|
||||
@patch(
|
||||
"bec_widgets.cli.rpc.rpc_widget_handler.get_all_plugin_widgets",
|
||||
return_value={"DeviceComboBox": _TestPluginWidget, "NewPluginWidget": _TestPluginWidget},
|
||||
return_value=BECClassContainer(
|
||||
[
|
||||
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""),
|
||||
BECClassInfo(name="NewPluginWidget", obj=_TestPluginWidget, module="", file=""),
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_duplicate_plugins_not_allowed(_):
|
||||
handler = RPCWidgetHandler()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
from unittest.mock import MagicMock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
|
||||
from qtpy.QtCore import QModelIndex, Qt
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import StrMetadataField
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
@@ -540,6 +542,29 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
|
||||
assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1}
|
||||
|
||||
|
||||
TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"}
|
||||
TEST_TABLE_ENTRY = [["test key 1", "test value 1"], ["test key 2", "test value 2"]]
|
||||
|
||||
|
||||
def test_scan_metadata_is_updated_even_without_default_form_changes(
|
||||
scan_control: ScanControl, qtbot
|
||||
):
|
||||
assert scan_control._metadata_form._scan_name == "line_scan"
|
||||
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
|
||||
assert scan_control._metadata_form._scan_name == "grid_scan"
|
||||
scan_control._metadata_form._additional_metadata._add_button.click()
|
||||
qtbot.wait(100)
|
||||
table_model = scan_control._metadata_form._additional_metadata._table_model
|
||||
model_key = table_model.index(0, 0, QModelIndex())
|
||||
table_model.setData(model_key, "test key 1", Qt.EditRole)
|
||||
model_value = model_key.siblingAtColumn(1)
|
||||
table_model.setData(model_value, "test value 1", Qt.EditRole)
|
||||
assert scan_control._metadata_form._additional_metadata.dump_dict() == {
|
||||
"test key 1": "test value 1"
|
||||
}
|
||||
assert scan_control._scan_metadata == {"sample_name": "", "test key 1": "test value 1"}
|
||||
|
||||
|
||||
def test_scan_metadata_is_connected(scan_control):
|
||||
assert scan_control._metadata_form._scan_name == "line_scan"
|
||||
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
|
||||
@@ -548,16 +573,28 @@ def test_scan_metadata_is_connected(scan_control):
|
||||
assert isinstance(sample_name, StrMetadataField)
|
||||
sample_name._main_widget.setText("Test Sample")
|
||||
|
||||
scan_control._metadata_form._additional_metadata._table_model._data = [
|
||||
["test key 1", "test value 1"],
|
||||
["test key 2", "test value 2"],
|
||||
]
|
||||
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
|
||||
scan_control._metadata_form.validate_form()
|
||||
assert scan_control._scan_metadata == {
|
||||
"sample_name": "Test Sample",
|
||||
"test key 1": "test value 1",
|
||||
"test key 2": "test value 2",
|
||||
}
|
||||
assert scan_control._scan_metadata == TEST_MD
|
||||
|
||||
|
||||
def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
|
||||
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
|
||||
|
||||
sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget()
|
||||
sample_name._main_widget.setText("Test Sample")
|
||||
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
|
||||
scan_control._metadata_form.validate_form()
|
||||
|
||||
assert scan_control._scan_metadata == TEST_MD
|
||||
|
||||
scans = SimpleNamespace(grid_scan=MagicMock())
|
||||
with (
|
||||
patch.object(scan_control, "scans", scans),
|
||||
patch.object(scan_control, "get_scan_parameters", lambda: ((), {})),
|
||||
):
|
||||
scan_control.run_scan()
|
||||
scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
|
||||
|
||||
|
||||
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
|
||||
|
||||
90
tests/unit_tests/test_web_console.py
Normal file
90
tests/unit_tests/test_web_console.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtNetwork import QAuthenticator
|
||||
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole, _web_console_registry
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot, mocked_client):
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.editors.web_console.web_console.subprocess"
|
||||
) as mock_subprocess:
|
||||
with mock.patch.object(_web_console_registry, "_wait_for_server_port"):
|
||||
_web_console_registry._server_port = 12345
|
||||
# Create the WebConsole widget
|
||||
widget = WebConsole(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_web_console_widget_initialization(console_widget):
|
||||
assert (
|
||||
console_widget.page.url().toString()
|
||||
== f"http://localhost:{_web_console_registry._server_port}"
|
||||
)
|
||||
|
||||
|
||||
def test_web_console_write(console_widget):
|
||||
# Test the write method
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.write("Hello, World!")
|
||||
|
||||
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
|
||||
|
||||
|
||||
def test_web_console_write_no_return(console_widget):
|
||||
# Test the write method with send_return=False
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.write("Hello, World!", send_return=False)
|
||||
|
||||
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
def test_web_console_send_return(console_widget):
|
||||
# Test the send_return method
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.send_return()
|
||||
|
||||
script = mock_run_js.call_args[0][0]
|
||||
assert "new KeyboardEvent('keypress', {charCode: 13})" in script
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
def test_web_console_send_ctrl_c(console_widget):
|
||||
# Test the send_ctrl_c method
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.send_ctrl_c()
|
||||
|
||||
script = mock_run_js.call_args[0][0]
|
||||
assert "new KeyboardEvent('keypress', {charCode: 3})" in script
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
def test_web_console_authenticate(console_widget):
|
||||
# Test the _authenticate method
|
||||
token = _web_console_registry._token
|
||||
mock_auth = mock.MagicMock(spec=QAuthenticator)
|
||||
console_widget._authenticate(None, mock_auth)
|
||||
mock_auth.setUser.assert_called_once_with("user")
|
||||
mock_auth.setPassword.assert_called_once_with(token)
|
||||
|
||||
|
||||
def test_web_console_registry_wait_for_server_port():
|
||||
# Test the _wait_for_server_port method
|
||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
||||
mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"]
|
||||
_web_console_registry._wait_for_server_port()
|
||||
assert _web_console_registry._server_port == 12345
|
||||
|
||||
|
||||
def test_web_console_registry_wait_for_server_port_timeout():
|
||||
# Test the _wait_for_server_port method with timeout
|
||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
||||
with pytest.raises(TimeoutError):
|
||||
_web_console_registry._wait_for_server_port(timeout=0.1)
|
||||
Reference in New Issue
Block a user