mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-14 04:30:54 +02:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392ddf9d1a | ||
| 85705383e4 | |||
|
|
224863569f | ||
| 3e2544e52a | |||
|
|
4d5daf6557 | ||
| 718116afc3 | |||
| 2dda58f7d2 | |||
| 594912136e | |||
| 5188b38c86 | |||
| a10e6f7820 | |||
| e0e26c205b | |||
| 92d1d6435d | |||
| a25c1a8039 | |||
|
|
fed068f857 | ||
| 7eb2f54e0e | |||
| 92b89e7275 | |||
| a4f3117941 | |||
| 3e789ca35b | |||
| 92dade0950 | |||
| 4a343b2041 | |||
| c2b0c8c433 | |||
| 8a299a8268 | |||
| 99ecf6a18f | |||
| 4c0bd977fc | |||
| 7c47505c5a | |||
| e211e4d716 | |||
| 10f292def9 | |||
|
|
d111ded737 | ||
| 2d0ed94f3f | |||
|
|
f68f072da3 | ||
| 1df6c1925b | |||
| 6b939ac34d | |||
|
|
6bcf20af07 | ||
| a64cf0dd87 | |||
| cd4e90a79f | |||
|
|
49a96a18d6 | ||
| 2b4454a291 | |||
| d12bd9fe1a | |||
| d0c1ac0cf5 | |||
| f90150d1c7 | |||
|
|
c684b6c230 | ||
| 91126168b6 | |||
| 7322cd194f | |||
| d9dc60ee99 | |||
|
|
e4cd4891ad | ||
| 12f8c82eb5 | |||
|
|
f46ffb14e1 | ||
| 2b9919bb34 | |||
| 822e7d06ff | |||
| 91195ae0fd | |||
| a6c5c21afa | |||
|
|
ff06954cb7 | ||
| c8128faf79 | |||
|
|
6b65a94c81 | ||
| bf172b8431 | |||
| 05329ab50f | |||
| 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 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Bug report
|
||||
|
||||
## Summary
|
||||
|
||||
[Provide a brief description of the bug.]
|
||||
|
||||
## Expected Behavior vs Actual Behavior
|
||||
|
||||
[Describe what you expected to happen and what actually happened.]
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Paste links to any related issues or feature requests.]
|
||||
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]
|
||||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,8 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
title: '[FEAT]: '
|
||||
type: feature
|
||||
label: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -1,5 +1,21 @@
|
||||
name: Full CI
|
||||
on: [push, pull_request]
|
||||
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
|
||||
@@ -17,6 +33,10 @@ jobs:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -24,6 +44,10 @@ jobs:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
|
||||
16
.github/workflows/end2end-conda.yml
vendored
16
.github/workflows/end2end-conda.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
@@ -39,10 +40,19 @@ jobs:
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
|
||||
cd ./bec
|
||||
conda create -q -n test-environment python=3.11
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e .[dev,pyside6]
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
9
.github/workflows/formatter.yml
vendored
9
.github/workflows/formatter.yml
vendored
@@ -14,10 +14,15 @@ jobs:
|
||||
|
||||
- name: Run black and isort
|
||||
run: |
|
||||
pip install black isort
|
||||
pip install -e .[dev]
|
||||
pip install uv
|
||||
uv pip install --system black isort
|
||||
uv pip install --system -e .[dev]
|
||||
black --check --diff --color .
|
||||
isort --check --diff ./
|
||||
|
||||
- name: Check for disallowed imports from PySide
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
|
||||
Pylint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
58
.github/workflows/pytest-matrix.yml
vendored
58
.github/workflows/pytest-matrix.yml
vendored
@@ -1,5 +1,26 @@
|
||||
name: Run Pytest with different Python versions
|
||||
on: [workflow_call]
|
||||
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:
|
||||
@@ -9,7 +30,7 @@ jobs:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
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 }}
|
||||
@@ -17,31 +38,20 @@ jobs:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install 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 .[dev,pyside6]
|
||||
- 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: |
|
||||
|
||||
53
.github/workflows/pytest.yml
vendored
53
.github/workflows/pytest.yml
vendored
@@ -6,6 +6,21 @@ on:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
@@ -20,39 +35,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install 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 .[dev,pyside6]
|
||||
- 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
|
||||
|
||||
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.]
|
||||
@@ -1,40 +0,0 @@
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
|
||||
## Problem Description
|
||||
|
||||
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
|
||||
|
||||
## Use Case
|
||||
|
||||
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
|
||||
|
||||
## Benefits
|
||||
|
||||
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
|
||||
|
||||
## Impact on Existing Functionality
|
||||
|
||||
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
|
||||
|
||||
## Additional Information
|
||||
|
||||
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
|
||||
|
||||
@@ -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]
|
||||
|
||||
388
CHANGELOG.md
388
CHANGELOG.md
@@ -1,6 +1,394 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.12.2 (2025-06-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Safeguard for history data access, closes #571; removed return values "none"
|
||||
([`8570538`](https://github.com/bec-project/bec_widgets/commit/85705383e4aff2f83f76d342db0a13380aeca42f))
|
||||
|
||||
|
||||
## v2.12.1 (2025-06-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Emitted name from crosshair 2D is objectName of image or its id
|
||||
([`3e2544e`](https://github.com/bec-project/bec_widgets/commit/3e2544e52a84b30a5acb4a7874025fa359a3c58d))
|
||||
|
||||
|
||||
## v2.12.0 (2025-06-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Exclude metadata from RPC
|
||||
([`718116a`](https://github.com/bec-project/bec_widgets/commit/718116afc3a724658c4cd57b76e93249a66a9ebd))
|
||||
|
||||
- Grid formatting in TypedForm
|
||||
([`5949121`](https://github.com/bec-project/bec_widgets/commit/594912136e2118de1a4de5213c2f668952f28a84))
|
||||
|
||||
- Make generate plugin robust to multiline init
|
||||
([`a10e6f7`](https://github.com/bec-project/bec_widgets/commit/a10e6f7820309d590e832f2bca44ca1db8ef72a1))
|
||||
|
||||
instead of str.find, use multiline regex with whitespace
|
||||
|
||||
- **device browser**: Mocks and utils for tests
|
||||
([`e0e26c2`](https://github.com/bec-project/bec_widgets/commit/e0e26c205bf930d680e01910f87489decc7fbcdb))
|
||||
|
||||
### Features
|
||||
|
||||
- (#493) add dict to dynamic form types
|
||||
([`92d1d64`](https://github.com/bec-project/bec_widgets/commit/92d1d6435d6e8c05851804eb76605a4abeec01bb))
|
||||
|
||||
- (#493) add helpers to dynamic form widgets
|
||||
([`a25c1a8`](https://github.com/bec-project/bec_widgets/commit/a25c1a8039078c92789b717b3f8a553c75814c33))
|
||||
|
||||
- (#493) device browser to display config
|
||||
([`5188b38`](https://github.com/bec-project/bec_widgets/commit/5188b38c86f543d2abc742411b64fa127c6c0c16))
|
||||
|
||||
- Add clickable label util
|
||||
([`2dda58f`](https://github.com/bec-project/bec_widgets/commit/2dda58f7d2adf1f41c6ce4fad02d55bd9aa200fa))
|
||||
|
||||
|
||||
## v2.11.0 (2025-06-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image item**: Propagate remove call to parent class
|
||||
([`e211e4d`](https://github.com/bec-project/bec_widgets/commit/e211e4d7161cc4fc4b2f7cd18f058e070f5b4b7a))
|
||||
|
||||
- **image layer**: Add layer main if it does not exist
|
||||
([`7eb2f54`](https://github.com/bec-project/bec_widgets/commit/7eb2f54e0ed556e0c30a4e14ded75e32dcf3d531))
|
||||
|
||||
- **image_item**: Do not disconnect the monitor from within the image item
|
||||
([`4c0bd97`](https://github.com/bec-project/bec_widgets/commit/4c0bd977fc2b82680bbace763f5ffb19ed664f72))
|
||||
|
||||
### Features
|
||||
|
||||
- **image_layer**: Add default name for image layers
|
||||
([`4a343b2`](https://github.com/bec-project/bec_widgets/commit/4a343b204112c53e593e9bb43642d21f268dfa85))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **image**: Disconnect when layer is removed
|
||||
([`8a299a8`](https://github.com/bec-project/bec_widgets/commit/8a299a8268f3c21bbdc6629ad1f1f50a0aa0948b))
|
||||
|
||||
- **image**: Introduce image base and image layer; rename vrange to v_range
|
||||
([`10f292d`](https://github.com/bec-project/bec_widgets/commit/10f292def9d1551bca0d8f63c0a94799c08ff507))
|
||||
|
||||
- **image**: Move image item creation to layer manager
|
||||
([`c2b0c8c`](https://github.com/bec-project/bec_widgets/commit/c2b0c8c4336302ec4a7807c31b3f3b78a413c1aa))
|
||||
|
||||
- **image**: Removed access to image item config
|
||||
([`99ecf6a`](https://github.com/bec-project/bec_widgets/commit/99ecf6a18f2e87d68f3de3abf56d97f7e6467912))
|
||||
|
||||
- **image_base**: Move default color map to image layer
|
||||
([`92b89e7`](https://github.com/bec-project/bec_widgets/commit/92b89e72750fc0ab72ea51f865032133c49a7f18))
|
||||
|
||||
- **image_base**: Renamed layers to layer_manager and added public methods for accessing the layer
|
||||
manager
|
||||
([`92dade0`](https://github.com/bec-project/bec_widgets/commit/92dade09508ff3940e0b5dc99917302d61b03bc8))
|
||||
|
||||
- **image_item**: Emit object name with removed signal
|
||||
([`a4f3117`](https://github.com/bec-project/bec_widgets/commit/a4f311794132c6c24370cb2f5b5e0725b12587fd))
|
||||
|
||||
- **image_item**: Removed outdated image item config
|
||||
([`3e789ca`](https://github.com/bec-project/bec_widgets/commit/3e789ca35b6d0cf2d8ae9677bc65b7f0ca4eabc7))
|
||||
|
||||
### Testing
|
||||
|
||||
- Improve error message for widgets that are not properly cleaned up
|
||||
([`7c47505`](https://github.com/bec-project/bec_widgets/commit/7c47505c5a147885ca2e854e13c1eb3fddaf5489))
|
||||
|
||||
|
||||
## v2.10.3 (2025-06-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **color_button_native**: Popup logic to choose color moved to ColorButtonNative
|
||||
([`2d0ed94`](https://github.com/bec-project/bec_widgets/commit/2d0ed94f3feb38dfc9645f2c3b9d6a06b92637bb))
|
||||
|
||||
|
||||
## v2.10.2 (2025-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove unnecessary PySide imports
|
||||
([`1df6c19`](https://github.com/bec-project/bec_widgets/commit/1df6c1925b6ec88df8d7a1a5a79a5ddc6b1161b5))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Check for disallowed imports from PySide
|
||||
([`6b939ac`](https://github.com/bec-project/bec_widgets/commit/6b939ac34d01cdbb0e8e32a0bd4e56cad032e75b))
|
||||
|
||||
|
||||
## v2.10.1 (2025-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **console**: Qt console widget deleted
|
||||
([`cd4e90a`](https://github.com/bec-project/bec_widgets/commit/cd4e90a79fcdbc96f4ec23db22375d05a48731db))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyte removed from dependencies
|
||||
([`a64cf0d`](https://github.com/bec-project/bec_widgets/commit/a64cf0dd871c1419e02d3803c74cc45966baac19))
|
||||
|
||||
|
||||
## v2.10.0 (2025-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Waveform only update async data when scan is currently running
|
||||
([`f90150d`](https://github.com/bec-project/bec_widgets/commit/f90150d1c708331d4ee78f82ebf5ef23cd81fd17))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add job logs to e2e test
|
||||
([`d12bd9f`](https://github.com/bec-project/bec_widgets/commit/d12bd9fe1a010babc94dc86405d1b75a2b07534c))
|
||||
|
||||
- Fix artifact version
|
||||
([`2b4454a`](https://github.com/bec-project/bec_widgets/commit/2b4454a291bc69399ddd08780c44e1339825fb36))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Large async dataset warning popup
|
||||
([`d0c1ac0`](https://github.com/bec-project/bec_widgets/commit/d0c1ac0cf5d421d14c9e050ccf5832cd30ca0764))
|
||||
|
||||
|
||||
## v2.9.2 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Logpanel error cycle
|
||||
([`d9dc60e`](https://github.com/bec-project/bec_widgets/commit/d9dc60ee9974e2e6e6005378cc17ef088a4ded2c))
|
||||
|
||||
- Move log panel to bec connector and add rate limiter
|
||||
([`7322cd1`](https://github.com/bec-project/bec_widgets/commit/7322cd194fcf7f56d41c86ecbcd97a5d8bd60c3e))
|
||||
|
||||
- **log_panel**: Removed lambda callback method
|
||||
([`9112616`](https://github.com/bec-project/bec_widgets/commit/91126168b62f3e1623521ceb205dd854287cfef7))
|
||||
|
||||
|
||||
## v2.9.1 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make registry update log message debug level
|
||||
([`12f8c82`](https://github.com/bec-project/bec_widgets/commit/12f8c82eb59ed6a7273b57126efe340bf37b65cc))
|
||||
|
||||
|
||||
## v2.9.0 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DeviceSignalInput**: Improve robustness
|
||||
([`91195ae`](https://github.com/bec-project/bec_widgets/commit/91195ae0fdf024daf2daaa4ea2963992b4e40e04))
|
||||
|
||||
use set for storing filter properties to allow multiple set to true or false
|
||||
|
||||
### Code Style
|
||||
|
||||
- Typing in bec_dispatcher
|
||||
([`a6c5c21`](https://github.com/bec-project/bec_widgets/commit/a6c5c21afaa6dcf33ce71027e8730354ee34e3b4))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add usage docs for signal label widget
|
||||
([`2b9919b`](https://github.com/bec-project/bec_widgets/commit/2b9919bb34a66708f4b910ffc17dc253e9b7f70d))
|
||||
|
||||
### Features
|
||||
|
||||
- (#569) add signal label widget
|
||||
([`822e7d0`](https://github.com/bec-project/bec_widgets/commit/822e7d06ff7479d006ae99942fed5e2c836831ce))
|
||||
|
||||
add a widget which shows the current value of a signal from BEC. configurable with many properties
|
||||
in designer. intended for use mainly in static GUIs.
|
||||
|
||||
|
||||
## v2.8.4 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Label decimal precision is dynamically scaled with the plot zoom; API of all
|
||||
affected widgets adjusted; option added to PlotBase; closes #637
|
||||
([`c8128fa`](https://github.com/bec-project/bec_widgets/commit/c8128faf79c43487921aada9dbf1869ef5bda93c))
|
||||
|
||||
|
||||
## v2.8.3 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Guard plugin repo import in e2e test
|
||||
([`bf172b8`](https://github.com/bec-project/bec_widgets/commit/bf172b8431ec207f39206d2a0446908f7186858a))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Store modules with widget search
|
||||
([`b225a7c`](https://github.com/bec-project/bec_widgets/commit/b225a7cc90b55697211c28d9411b6f85c8077217))
|
||||
|
||||
### Testing
|
||||
|
||||
- **e2e**: Add tests involving plugin repo
|
||||
([`05329ab`](https://github.com/bec-project/bec_widgets/commit/05329ab50fe10ffc3c19ef3eb408912bb9068de3))
|
||||
|
||||
|
||||
## v2.8.2 (2025-05-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi**: Rois are invertible by default, fixes resizing bug when adding from ROI manager
|
||||
([`0bdd4e8`](https://github.com/bec-project/bec_widgets/commit/0bdd4e86a24a61b5365febcb2fcbde0532117053))
|
||||
|
||||
|
||||
## v2.8.1 (2025-05-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **launch_window**: Font and tile size fixed across OSs, closes #607
|
||||
([`ada0977`](https://github.com/bec-project/bec_widgets/commit/ada0977a1b50e750c2e2c848ce9b80895e0e524a))
|
||||
|
||||
|
||||
## v2.8.0 (2025-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ImageProcessing**: Use target widget as parent
|
||||
([`d8547c7`](https://github.com/bec-project/bec_widgets/commit/d8547c7a56cea72dd41a2020c47adfd93969139f))
|
||||
|
||||
### Features
|
||||
|
||||
- **plot_base**: Add option to specify units
|
||||
([`3484507`](https://github.com/bec-project/bec_widgets/commit/3484507c75500dc1b1a53853ff01937ad9ad8913))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **server**: Minor cleanup of imports
|
||||
([`8abebb7`](https://github.com/bec-project/bec_widgets/commit/8abebb72862c44d32a24f5e692319dec7a0891bf))
|
||||
|
||||
- **toolbar**: Add warning if no parent is provided as it may lead to segfaults
|
||||
([`4f69f5d`](https://github.com/bec-project/bec_widgets/commit/4f69f5da45420d92fd985801a8920ecf10166554))
|
||||
|
||||
|
||||
## v2.7.1 (2025-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **signal-combobox**: Bug fix in signal combobox that crashed upon switching from device to signal
|
||||
input
|
||||
([`1a4eb1d`](https://github.com/bec-project/bec_widgets/commit/1a4eb1db67ff6cfc45ce91cd264ae2818a57230a))
|
||||
|
||||
- **signal-line-edit**: Fix signal_line_edit validity check; closes #610
|
||||
([`ec740d3`](https://github.com/bec-project/bec_widgets/commit/ec740d31fdea561f1ed9274ea79b7be3b6ecba11))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Add rpc interface to signal_line_edit/combobox; add user access methods
|
||||
([`a8811c9`](https://github.com/bec-project/bec_widgets/commit/a8811c9d914feacf08f2f1f1aaf16302cd320ba3))
|
||||
|
||||
### Testing
|
||||
|
||||
- **input-widgets**: Add e2e tests to test widget inputs with demo config of bec.
|
||||
([`f57950c`](https://github.com/bec-project/bec_widgets/commit/f57950c4e3b0b5eab7bc303eaead89f7e50e2804))
|
||||
|
||||
|
||||
## v2.7.0 (2025-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image/image_selecetion**: Toolbar selection tool size adjusted
|
||||
([`e12e9e5`](https://github.com/bec-project/bec_widgets/commit/e12e9e534d6913223b741bff31bed6674ae4c0e6))
|
||||
|
||||
- **plot_base/mouse_interactions.py**: Fixed parent
|
||||
([`66e9445`](https://github.com/bec-project/bec_widgets/commit/66e9445760f2796c008d08feba54c3d48e4a9cfb))
|
||||
|
||||
### Features
|
||||
|
||||
- **image**: Roi plots with crosshair cuts added
|
||||
([`ce88787`](https://github.com/bec-project/bec_widgets/commit/ce88787e881d12384dd3a25b75fadda1f2280c81))
|
||||
|
||||
|
||||
## v2.6.0 (2025-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi**: Position can be set from rpc
|
||||
([`41b7ca8`](https://github.com/bec-project/bec_widgets/commit/41b7ca8e649d39dd21d09febfa8aabfc8f6f98fc))
|
||||
|
||||
### Chores
|
||||
|
||||
- Migrate issue template to github form syntax
|
||||
([`05489a1`](https://github.com/bec-project/bec_widgets/commit/05489a1c563e20a49fe34d4df97ca0c3c23d8634))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add pr issue sync
|
||||
([`53377d2`](https://github.com/bec-project/bec_widgets/commit/53377d26e2767b3df7c788330c4d592fc12051ed))
|
||||
|
||||
### Features
|
||||
|
||||
- **image_roi_tree**: Gui roi manager for image widget
|
||||
([`a939c3b`](https://github.com/bec-project/bec_widgets/commit/a939c3b1c4a7bcf1322f2d1d330fdb721ea04d56))
|
||||
|
||||
- **waveform**: Lmfitdialog cleanup after close
|
||||
([`a020f2d`](https://github.com/bec-project/bec_widgets/commit/a020f2dc7e537493ce4aff5d88ea003956624869))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **image_roi**: Glowing handles for Rectangle roi
|
||||
([`7a531c1`](https://github.com/bec-project/bec_widgets/commit/7a531c17d6a4411550600ddc8bb9d56ee777259d))
|
||||
|
||||
|
||||
## v2.5.4 (2025-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock_area**: Menu to add LogPanel into DockArea is temporary disabled
|
||||
([`d4def09`](https://github.com/bec-project/bec_widgets/commit/d4def09a4ecc024fd7e0e90fd975799066e7bb58))
|
||||
|
||||
|
||||
## v2.5.3 (2025-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **server**: Simplefilelikefromlogoutputfunc added encoding for stdout
|
||||
([`bcab66b`](https://github.com/bec-project/bec_widgets/commit/bcab66b1871fcb522a99859bde0c35bda2570e3a))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Reusable actions for installing bec widgets
|
||||
([`a345253`](https://github.com/bec-project/bec_widgets/commit/a345253c6e6ca7e4dba710b91c39cba0085251e5))
|
||||
|
||||
|
||||
## v2.5.2 (2025-05-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Update gitignore
|
||||
([`f8276f0`](https://github.com/bec-project/bec_widgets/commit/f8276f02245d4263e94dfd018060aef28d787f25))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix build process for sphinx
|
||||
([`8227c44`](https://github.com/bec-project/bec_widgets/commit/8227c44c33d573874b9a4c74ae9a03a370adcb18))
|
||||
|
||||
|
||||
## v2.5.1 (2025-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ui loader**: Fix loader for widget plugins
|
||||
([`a7ae856`](https://github.com/bec-project/bec_widgets/commit/a7ae856c8f073b4af10b0f0b129dba4fc02bc2aa))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add kwargs to example
|
||||
([`06f43e4`](https://github.com/bec-project/bec_widgets/commit/06f43e488355470331a3bdf28cfe973a4440fc6d))
|
||||
|
||||
- **developer**: Fix hello world example
|
||||
([`5ec9697`](https://github.com/bec-project/bec_widgets/commit/5ec969727116cee6e7fb4ab05c0e9ab142f24be6))
|
||||
|
||||
|
||||
## v2.5.0 (2025-05-20)
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
@@ -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,6 +51,9 @@ _Widgets = {
|
||||
"RingProgressBar": "RingProgressBar",
|
||||
"ScanControl": "ScanControl",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
"SignalLabel": "SignalLabel",
|
||||
"SignalLineEdit": "SignalLineEdit",
|
||||
"StopButton": "StopButton",
|
||||
"TextBox": "TextBox",
|
||||
"VSCodeEditor": "VSCodeEditor",
|
||||
@@ -61,7 +64,7 @@ _Widgets = {
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -602,6 +605,16 @@ class BaseROI(RPCBase):
|
||||
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."""
|
||||
@@ -701,6 +714,16 @@ class CircularROI(RPCBase):
|
||||
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
|
||||
@@ -919,9 +942,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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -939,9 +975,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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1163,6 +1222,20 @@ class Image(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -1179,16 +1252,16 @@ class Image(RPCBase):
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def vrange(self) -> "tuple":
|
||||
def v_range(self) -> "QPointF":
|
||||
"""
|
||||
Get the vrange of the image.
|
||||
Set the v_range of the main image.
|
||||
"""
|
||||
|
||||
@vrange.setter
|
||||
@v_range.setter
|
||||
@rpc_call
|
||||
def vrange(self) -> "tuple":
|
||||
def v_range(self) -> "QPointF":
|
||||
"""
|
||||
Get the vrange of the image.
|
||||
Set the v_range of the main image.
|
||||
"""
|
||||
|
||||
@property
|
||||
@@ -1418,7 +1491,7 @@ class Image(RPCBase):
|
||||
self,
|
||||
kind: "Literal['rect', 'circle']" = "rect",
|
||||
name: "str | None" = None,
|
||||
line_width: "int | None" = 10,
|
||||
line_width: "int | None" = 5,
|
||||
pos: "tuple[float, float] | None" = (10, 10),
|
||||
size: "tuple[float, float] | None" = (50, 50),
|
||||
**pg_kwargs,
|
||||
@@ -2292,6 +2365,20 @@ class MultiWaveform(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def highlighted_index(self):
|
||||
@@ -2652,6 +2739,16 @@ class RectangularROI(RPCBase):
|
||||
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."""
|
||||
@@ -3247,6 +3344,20 @@ class ScatterWaveform(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def main_curve(self) -> "ScatterCurve":
|
||||
@@ -3317,6 +3428,152 @@ 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 SignalLabel(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def custom_label(self) -> "str":
|
||||
"""
|
||||
Use a cusom label rather than the signal name
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def custom_units(self) -> "str":
|
||||
"""
|
||||
Use a custom unit string
|
||||
"""
|
||||
|
||||
@custom_label.setter
|
||||
@rpc_call
|
||||
def custom_label(self) -> "str":
|
||||
"""
|
||||
Use a cusom label rather than the signal name
|
||||
"""
|
||||
|
||||
@custom_units.setter
|
||||
@rpc_call
|
||||
def custom_units(self) -> "str":
|
||||
"""
|
||||
Use a custom unit string
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def decimal_places(self) -> "int":
|
||||
"""
|
||||
Format to a given number of decimal_places. Set to 0 to disable.
|
||||
"""
|
||||
|
||||
@decimal_places.setter
|
||||
@rpc_call
|
||||
def decimal_places(self) -> "int":
|
||||
"""
|
||||
Format to a given number of decimal_places. Set to 0 to disable.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_default_units(self) -> "bool":
|
||||
"""
|
||||
Show default units obtained from the signal alongside it
|
||||
"""
|
||||
|
||||
@show_default_units.setter
|
||||
@rpc_call
|
||||
def show_default_units(self) -> "bool":
|
||||
"""
|
||||
Show default units obtained from the signal alongside it
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_select_button(self) -> "bool":
|
||||
"""
|
||||
Show the button to select the signal to display
|
||||
"""
|
||||
|
||||
@show_select_button.setter
|
||||
@rpc_call
|
||||
def show_select_button(self) -> "bool":
|
||||
"""
|
||||
Show the button to select the signal to display
|
||||
"""
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -3647,6 +3904,20 @@ class Waveform(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
@@ -3699,6 +3970,48 @@ class Waveform(RPCBase):
|
||||
The color palette of the figure widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def skip_large_dataset_warning(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@skip_large_dataset_warning.setter
|
||||
@rpc_call
|
||||
def skip_large_dataset_warning(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def skip_large_dataset_check(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@skip_large_dataset_check.setter
|
||||
@rpc_call
|
||||
def skip_large_dataset_check(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def max_dataset_size_mb(self) -> "float":
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
|
||||
@max_dataset_size_mb.setter
|
||||
@rpc_call
|
||||
def max_dataset_size_mb(self) -> "float":
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
#
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
self.im = Image(popups=False)
|
||||
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")
|
||||
|
||||
@@ -184,8 +184,8 @@ class FakePositioner(BECPositioner):
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0):
|
||||
super().__init__(name, limits, read_value)
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class BECDispatcher:
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
|
||||
cb_info: dict | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
@@ -172,7 +172,7 @@ class BECDispatcher:
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
"""
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
@@ -183,13 +183,15 @@ class BECDispatcher:
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
def disconnect_slot(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
|
||||
"""
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
|
||||
@@ -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())
|
||||
...
|
||||
|
||||
13
bec_widgets/utils/clickable_label.py
Normal file
13
bec_widgets/utils/clickable_label.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
from qtpy.QtWidgets import QLabel
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
|
||||
self.clicked.emit()
|
||||
return super().mouseReleaseEvent(ev)
|
||||
@@ -15,12 +15,15 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
theme = "dark"
|
||||
return "dark"
|
||||
else:
|
||||
theme = QApplication.instance().theme.theme
|
||||
return bec_qthemes.load_palette(theme)
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
return bec_qthemes.load_palette(get_theme_name())
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
|
||||
@@ -34,13 +34,21 @@ class Crosshair(QObject):
|
||||
coordinatesChanged2D = Signal(tuple)
|
||||
coordinatesClicked2D = Signal(tuple)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
plot_item: pg.PlotItem,
|
||||
precision: int | None = None,
|
||||
*,
|
||||
min_precision: int = 2,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
|
||||
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
|
||||
parent (QObject, optional): Parent object for the QObject. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
@@ -48,7 +56,9 @@ class Crosshair(QObject):
|
||||
self.is_log_x = None
|
||||
self.is_derivative = None
|
||||
self.plot_item = plot_item
|
||||
self.precision = precision
|
||||
self._precision = precision
|
||||
self._min_precision = max(0, int(min_precision)) # ensure non‑negative
|
||||
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.v_line.skip_auto_range = True
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False)
|
||||
@@ -85,13 +95,64 @@ 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()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@property
|
||||
def precision(self) -> int | None:
|
||||
"""Fixed number of decimals; ``None`` enables dynamic mode."""
|
||||
return self._precision
|
||||
|
||||
@precision.setter
|
||||
def precision(self, value: int | None):
|
||||
"""
|
||||
Set the fixed number of decimals to display.
|
||||
|
||||
Args:
|
||||
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
|
||||
"""
|
||||
self._precision = value
|
||||
|
||||
@property
|
||||
def min_precision(self) -> int:
|
||||
"""Lower bound on decimals when dynamic precision is used."""
|
||||
return self._min_precision
|
||||
|
||||
@min_precision.setter
|
||||
def min_precision(self, value: int):
|
||||
"""
|
||||
Set the lower bound on decimals when dynamic precision is used.
|
||||
|
||||
Args:
|
||||
value(int): The minimum number of decimals to display. Must be non-negative.
|
||||
"""
|
||||
self._min_precision = max(0, int(value))
|
||||
|
||||
def _current_precision(self) -> int:
|
||||
"""
|
||||
Get the current precision based on the view range or fixed precision.
|
||||
"""
|
||||
if self._precision is not None:
|
||||
return self._precision
|
||||
|
||||
# Dynamically choose precision from the smaller visible span
|
||||
view_range = self.plot_item.vb.viewRange()
|
||||
x_span = abs(view_range[0][1] - view_range[0][0])
|
||||
y_span = abs(view_range[1][1] - view_range[1][0])
|
||||
|
||||
# Ignore zero spans that can appear during initialisation
|
||||
spans = [s for s in (x_span, y_span) if s > 0]
|
||||
span = min(spans) if spans else 1.0
|
||||
|
||||
exponent = np.floor(np.log10(span)) # order of magnitude
|
||||
decimals = max(0, int(-exponent) + 1)
|
||||
return max(self._min_precision, decimals)
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
@@ -195,13 +256,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
|
||||
@@ -241,8 +312,10 @@ class Crosshair(QObject):
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.objectName() 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))
|
||||
@@ -311,6 +384,7 @@ class Crosshair(QObject):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
@@ -321,16 +395,19 @@ class Crosshair(QObject):
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.objectName() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
# 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:
|
||||
@@ -364,6 +441,7 @@ class Crosshair(QObject):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
@@ -375,8 +453,8 @@ class Crosshair(QObject):
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
@@ -384,7 +462,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:
|
||||
@@ -424,14 +505,17 @@ class Crosshair(QObject):
|
||||
"""
|
||||
x, y = pos
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
|
||||
precision = self._current_precision()
|
||||
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
image = item.image
|
||||
if image is None:
|
||||
continue
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{self.precision}g}"
|
||||
text += f"\nIntensity: {intensity:.{precision}f}"
|
||||
break
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(text)
|
||||
@@ -450,9 +534,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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -12,15 +14,20 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.clickable_label import ClickableLabel
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
expansion_state_changed = Signal()
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
COLLAPSED_ICON_NAME: str = "expand_all"
|
||||
|
||||
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
@@ -29,19 +36,28 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_icon()
|
||||
self._title = QLabel(f"<b>{title}</b>")
|
||||
self._title_layout.addWidget(self._expansion_button)
|
||||
|
||||
self._title = ClickableLabel(f"<b>{title}</b>")
|
||||
self._title_icon = ClickableLabel()
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
@@ -50,7 +66,8 @@ class ExpandableGroupFrame(QFrame):
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_icon()
|
||||
self._update_expansion_icon()
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
@@ -61,8 +78,9 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
self.adjustSize()
|
||||
|
||||
def _update_icon(self):
|
||||
def _update_expansion_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
if self.expanded
|
||||
@@ -70,3 +88,36 @@ class ExpandableGroupFrame(QFrame):
|
||||
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def icon_name(self): # type: ignore
|
||||
return self._title_icon_name
|
||||
|
||||
@icon_name.setter
|
||||
def icon_name(self, icon_name: str):
|
||||
self._title_icon_name = icon_name
|
||||
self._set_title_icon(self._title_icon_name)
|
||||
|
||||
def _set_title_icon(self, icon_name: str):
|
||||
if icon_name:
|
||||
self._title_icon.setVisible(True)
|
||||
self._title_icon.setPixmap(
|
||||
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
|
||||
)
|
||||
else:
|
||||
self._title_icon.setVisible(False)
|
||||
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication([])
|
||||
frame = ExpandableGroupFrame()
|
||||
layout = QVBoxLayout()
|
||||
frame.set_layout(layout)
|
||||
layout.addWidget(QLabel("test1"))
|
||||
layout.addWidget(QLabel("test2"))
|
||||
layout.addWidget(QLabel("test3"))
|
||||
|
||||
frame.show()
|
||||
app.exec()
|
||||
|
||||
@@ -2,70 +2,99 @@ from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import NoneType
|
||||
from typing import NamedTuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
DynamicFormItemType,
|
||||
FormItemSpec,
|
||||
widget_from_type,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class GridRow(NamedTuple):
|
||||
i: int
|
||||
label: QLabel
|
||||
widget: DynamicFormItem
|
||||
|
||||
|
||||
class TypedForm(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "list_alt"
|
||||
|
||||
value_changed = Signal()
|
||||
|
||||
RPC = False
|
||||
RPC = True
|
||||
USER_ACCESS = ["enabled", "enabled.setter"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
items: list[tuple[str, type]] | None = None,
|
||||
form_item_specs: list[FormItemSpec] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Widget with a list of form items based on a list of types.
|
||||
|
||||
Args:
|
||||
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
||||
Should be a type supported by the logic in items.py
|
||||
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
||||
only one of items or form_item_specs should be
|
||||
supplied.
|
||||
|
||||
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
||||
Should be a type supported by the logic in items.py
|
||||
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
||||
only one of items or form_item_specs should be
|
||||
supplied.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
"""
|
||||
if (items is not None and form_item_specs is not None) or (
|
||||
items is None and form_item_specs is None
|
||||
):
|
||||
raise ValueError("Must specify one and only one of items and form_item_specs")
|
||||
if items is not None and form_item_specs is not None:
|
||||
logger.error(
|
||||
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
|
||||
)
|
||||
items = None
|
||||
if items is None and form_item_specs is None:
|
||||
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||
items = []
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._items = (
|
||||
form_item_specs
|
||||
if form_item_specs is not None
|
||||
else [
|
||||
FormItemSpec(name=name, item_type=item_type)
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
)
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._enabled: bool = enabled
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(
|
||||
QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding
|
||||
)
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self.populate()
|
||||
self.enabled = self._enabled # type: ignore # QProperty
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
@@ -80,17 +109,20 @@ class TypedForm(BECWidget, QWidget):
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = widget_from_type(item.item_type)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
|
||||
def enumerate_form_widgets(self):
|
||||
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
||||
which the field name is attached as a property), and the entry widget"""
|
||||
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
||||
for i in range(grid.rowCount()):
|
||||
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
|
||||
return {
|
||||
grid.itemAtPosition(i, 0)
|
||||
.widget()
|
||||
.property("_model_field_name"): grid.itemAtPosition(i, 1)
|
||||
.widget()
|
||||
.getValue() # type: ignore # we only add 'DynamicFormItem's here
|
||||
for i in range(grid.rowCount())
|
||||
row.label.property("_model_field_name"): row.widget.getValue()
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
@@ -103,10 +135,13 @@ class TypedForm(BECWidget, QWidget):
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
self._form_grid.adjustSize()
|
||||
self._form_grid_container.adjustSize()
|
||||
self.adjustSize()
|
||||
@@ -114,23 +149,52 @@ class TypedForm(BECWidget, QWidget):
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
return new_grid
|
||||
|
||||
@property
|
||||
def widget_dict(self):
|
||||
return {
|
||||
row.label.property("_model_field_name"): row.widget
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enabled(self):
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
self.setEnabled(value)
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
|
||||
def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
data_model: type[BaseModel] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
A form generated from a pydantic model.
|
||||
|
||||
Args:
|
||||
metadata_model (type[BaseModel]): the model class for which to generate a form.
|
||||
data_model (type[BaseModel]): the model class for which to generate a form.
|
||||
enabled (bool): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
|
||||
"""
|
||||
self._md_schema = metadata_model
|
||||
super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client)
|
||||
self._pretty_display = pretty_display
|
||||
self._md_schema = data_model
|
||||
super().__init__(
|
||||
parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client
|
||||
)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
@@ -147,9 +211,24 @@ class PydanticModelForm(TypedForm):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
def set_data(self, data: BaseModel):
|
||||
"""Fill the data for the form.
|
||||
|
||||
Args:
|
||||
data (BaseModel): the data to enter into the form. Must be the same type as the
|
||||
currently set schema, raises TypeError otherwise."""
|
||||
if not self._md_schema:
|
||||
raise ValueError("Schema not set - can't set data")
|
||||
if not isinstance(data, self._md_schema):
|
||||
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
|
||||
for form_item in self.enumerate_form_widgets():
|
||||
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
|
||||
|
||||
def _form_item_specs(self):
|
||||
return [
|
||||
FormItemSpec(name=name, info=info, item_type=info.annotation)
|
||||
FormItemSpec(
|
||||
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
|
||||
)
|
||||
for name, info in self._md_schema.model_fields.items()
|
||||
]
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import UnionType
|
||||
from typing import Callable, Protocol
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
@@ -21,11 +21,13 @@ from qtpy.QtWidgets import (
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
field_default,
|
||||
@@ -46,9 +48,36 @@ class FormItemSpec(BaseModel):
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
item_type: type | UnionType
|
||||
|
||||
item_type: type | UnionType | GenericAlias
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
pretty_display: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
|
||||
)
|
||||
|
||||
@field_validator("item_type", mode="before")
|
||||
@classmethod
|
||||
def _validate_type(cls, v):
|
||||
allowed_primitives = [str, int, float, bool]
|
||||
if isinstance(v, (type, UnionType)):
|
||||
return v
|
||||
if isinstance(v, GenericAlias):
|
||||
if v.__origin__ in [list, dict] and all(
|
||||
arg in allowed_primitives for arg in v.__args__
|
||||
):
|
||||
return v
|
||||
raise ValueError(
|
||||
f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}"
|
||||
)
|
||||
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
|
||||
arg_types = set(type(arg) for arg in v.__args__)
|
||||
if len(arg_types) != 1:
|
||||
raise ValueError("Mixtures of literal types are not supported!")
|
||||
if (t := arg_types.pop()) in allowed_primitives:
|
||||
return t
|
||||
raise ValueError(f"Literals of type {t} are not supported")
|
||||
|
||||
|
||||
class ClearableBoolEntry(QWidget):
|
||||
@@ -94,10 +123,20 @@ class ClearableBoolEntry(QWidget):
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
"""
|
||||
Initializes the form item widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None, optional): The parent widget. Defaults to None.
|
||||
spec (FormItemSpec): The specification for the form item.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._spec = spec
|
||||
self._layout = QHBoxLayout()
|
||||
@@ -107,11 +146,16 @@ class DynamicFormItem(QWidget):
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
self._main_widget: QWidget
|
||||
self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._set_pretty_display()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self): ...
|
||||
def getValue(self) -> DynamicFormItemType: ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
@@ -121,6 +165,9 @@ class DynamicFormItem(QWidget):
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
@@ -164,7 +211,7 @@ class StrMetadataField(DynamicFormItem):
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
self._main_widget.setText("")
|
||||
self._main_widget.setText(value)
|
||||
self._main_widget.setText(str(value))
|
||||
|
||||
|
||||
class IntMetadataField(DynamicFormItem):
|
||||
@@ -202,12 +249,12 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
precision = field_precision(self._spec.info)
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
min_, max_ = field_limits(self._spec.info, float, precision)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
precision = field_precision(self._spec.info)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
@@ -224,10 +271,10 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float):
|
||||
def setValue(self, value: float | Decimal):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
self._main_widget.setValue(float(value))
|
||||
|
||||
|
||||
class BoolMetadataField(DynamicFormItem):
|
||||
@@ -251,6 +298,27 @@ class BoolMetadataField(DynamicFormItem):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
class DictMetadataField(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.data_changed.connect(self._value_changed)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self._main_widget.set_button_visibility(False)
|
||||
super()._set_pretty_display()
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = DictBackedTable(self, [])
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.dump_dict()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
|
||||
if annotation in [str, str | None]:
|
||||
return StrMetadataField
|
||||
@@ -260,6 +328,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
|
||||
return FloatDecimalMetadataField
|
||||
if annotation in [bool, bool | None]:
|
||||
return BoolMetadataField
|
||||
if annotation in [dict, dict | None] or (
|
||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
|
||||
):
|
||||
return DictMetadataField
|
||||
if annotation in [list, list | None] or (
|
||||
isinstance(annotation, GenericAlias) and annotation.__origin__ is list
|
||||
):
|
||||
return StrMetadataField
|
||||
else:
|
||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||
return StrMetadataField
|
||||
|
||||
21
bec_widgets/utils/forms_from_types/styles.py
Normal file
21
bec_widgets/utils/forms_from_types/styles.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import bec_qthemes
|
||||
|
||||
|
||||
def pretty_display_theme(theme: str = "dark"):
|
||||
palette = bec_qthemes.load_palette(theme)
|
||||
foreground = palette.text().color().name()
|
||||
background = palette.base().color().name()
|
||||
border = palette.shadow().color().name()
|
||||
accent = palette.accent().color().name()
|
||||
return f"""
|
||||
QWidget {{color: {foreground}; background-color: {background}}}
|
||||
QLabel {{ font-weight: bold; }}
|
||||
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
|
||||
QRadioButton {{ color: {foreground}; }}
|
||||
QRadioButton::indicator::checked {{ color: {accent}; }}
|
||||
QCheckBox {{ color: {accent}; }}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(pretty_display_theme())
|
||||
@@ -8,6 +8,9 @@ from qtpy.QtCore import QObject
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
|
||||
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
|
||||
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
|
||||
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
|
||||
|
||||
|
||||
class PluginFilenames(NamedTuple):
|
||||
@@ -90,34 +93,20 @@ class DesignerPluginGenerator:
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
|
||||
cls_init_found = class_re.search(init_source) is not None
|
||||
super_self_re = re.compile(
|
||||
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
|
||||
)
|
||||
super_init_found = super_self_re.search(init_source) is not None
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
|
||||
# for the new style classes, we only have one super call. We can therefore check if the
|
||||
# number of __init__ calls is 2 (the class itself and the super class)
|
||||
num_inits = re.findall(r"__init__", init_source)
|
||||
if len(num_inits) == 2 and not super_init_found:
|
||||
super_init_found = bool(
|
||||
init_source.find("super().__init__(parent=parent") > 0
|
||||
or init_source.find("super().__init__(parent,") > 0
|
||||
or init_source.find("super().__init__(parent)") > 0
|
||||
)
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -195,7 +195,7 @@ class RPCServer:
|
||||
return
|
||||
self._broadcasted_data = data
|
||||
|
||||
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from qtpy.QtGui import QCloseEvent
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy import PYQT6, PYSIDE6
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import Signal
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Slot
|
||||
from qtpy.QtCore import Property
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
@@ -49,7 +50,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
|
||||
self._device = None
|
||||
self.get_bec_shortcuts()
|
||||
self._signal_filter = []
|
||||
self._signal_filter = set()
|
||||
self._signals = []
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
@@ -60,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
|
||||
### Qt Slots ###
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_signal(self, signal: str):
|
||||
"""
|
||||
Set the signal.
|
||||
@@ -76,10 +77,10 @@ class DeviceSignalInputBase(BECWidget):
|
||||
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
|
||||
)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(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.
|
||||
@@ -90,8 +91,8 @@ class DeviceSignalInputBase(BECWidget):
|
||||
self._device = device
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Slot(dict, dict)
|
||||
@Slot()
|
||||
@SafeSlot(dict, dict)
|
||||
@SafeSlot()
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
@@ -112,9 +113,12 @@ 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"]
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
def _update(kind: Kind):
|
||||
return [
|
||||
@@ -155,9 +159,9 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@include_hinted_signals.setter
|
||||
def include_hinted_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.append(Kind.hinted)
|
||||
self._signal_filter.add(Kind.hinted)
|
||||
else:
|
||||
self._signal_filter.remove(Kind.hinted)
|
||||
self._signal_filter.discard(Kind.hinted)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@@ -168,9 +172,9 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@include_normal_signals.setter
|
||||
def include_normal_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.append(Kind.normal)
|
||||
self._signal_filter.add(Kind.normal)
|
||||
else:
|
||||
self._signal_filter.remove(Kind.normal)
|
||||
self._signal_filter.discard(Kind.normal)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@@ -181,9 +185,9 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@include_config_signals.setter
|
||||
def include_config_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.append(Kind.config)
|
||||
self._signal_filter.add(Kind.config)
|
||||
else:
|
||||
self._signal_filter.remove(Kind.config)
|
||||
self._signal_filter.discard(Kind.config)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
### Properties and Methods ###
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBase,
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,8 +25,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)
|
||||
|
||||
@@ -32,7 +37,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceSignalInputBase = None,
|
||||
config: DeviceSignalInputBaseConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
device: str | None = None,
|
||||
signal_filter: str | list[str] | None = None,
|
||||
@@ -62,9 +67,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
|
||||
def update_signals_from_filters(self):
|
||||
@SafeSlot()
|
||||
@SafeSlot(dict, dict)
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
"""Update the filters for the combobox"""
|
||||
super().update_signals_from_filters()
|
||||
super().update_signals_from_filters(content, metadata)
|
||||
# pylint: disable=protected-access
|
||||
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
|
||||
if len(self._config_signals) > 0:
|
||||
@@ -81,7 +90,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.insertItem(0, "Hinted Signals")
|
||||
self.model().item(0).setEnabled(False)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def on_text_changed(self, text: str):
|
||||
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
|
||||
For a positioner, the readback value has to be renamed to the device name.
|
||||
|
||||
@@ -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_()
|
||||
|
||||
@@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.config.allowed_scans = allowed_scans
|
||||
|
||||
self._scan_metadata: dict | None = None
|
||||
self._metadata_form = ScanMetadata(parent=self)
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
@@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.layout.addStretch()
|
||||
|
||||
def _add_metadata_form(self):
|
||||
self._metadata_form = ScanMetadata(parent=self)
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
|
||||
@@ -1,870 +0,0 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell.
|
||||
|
||||
BECConsole VT100 emulation is powered by Pyte,
|
||||
(https://github.com/selectel/pyte).
|
||||
"""
|
||||
|
||||
import collections
|
||||
import fcntl
|
||||
import html
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pyte
|
||||
from pygments.token import Token
|
||||
from pyte.screens import History
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import Property as pyqtProperty
|
||||
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
"green": "#00CD00",
|
||||
"brown": "#996633", # Brown, replacing the yellow
|
||||
"blue": "#0000EE",
|
||||
"magenta": "#CD00CD",
|
||||
"cyan": "#00CDCD",
|
||||
"white": "#E5E5E5",
|
||||
"brightblack": "#7F7F7F",
|
||||
"brightred": "#FF0000",
|
||||
"brightgreen": "#00FF00",
|
||||
"brightyellow": "#FFFF00",
|
||||
"brightblue": "#5C5CFF",
|
||||
"brightmagenta": "#FF00FF",
|
||||
"brightcyan": "#00FFFF",
|
||||
"brightwhite": "#FFFFFF",
|
||||
}
|
||||
|
||||
control_keys_mapping = {
|
||||
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
|
||||
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
|
||||
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
|
||||
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
|
||||
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
|
||||
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
|
||||
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
|
||||
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
|
||||
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Return: b"\n",
|
||||
QtCore.Qt.Key_Space: b" ",
|
||||
QtCore.Qt.Key_Enter: b"\n",
|
||||
QtCore.Qt.Key_Tab: b"\t",
|
||||
QtCore.Qt.Key_Backspace: b"\x08",
|
||||
QtCore.Qt.Key_Home: b"\x47",
|
||||
QtCore.Qt.Key_End: b"\x4f",
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0e",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
QtCore.Qt.Key_F2: b"\x1b\x32",
|
||||
QtCore.Qt.Key_F3: b"\x1b\x33",
|
||||
QtCore.Qt.Key_F4: b"\x1b\x34",
|
||||
QtCore.Qt.Key_F5: b"\x1b\x35",
|
||||
QtCore.Qt.Key_F6: b"\x1b\x36",
|
||||
QtCore.Qt.Key_F7: b"\x1b\x37",
|
||||
QtCore.Qt.Key_F8: b"\x1b\x38",
|
||||
QtCore.Qt.Key_F9: b"\x1b\x39",
|
||||
QtCore.Qt.Key_F10: b"\x1b\x30",
|
||||
QtCore.Qt.Key_F11: b"\x45",
|
||||
QtCore.Qt.Key_F12: b"\x46",
|
||||
}
|
||||
|
||||
|
||||
def QtKeyToAscii(event):
|
||||
"""
|
||||
Convert the Qt key event to the corresponding ASCII sequence for
|
||||
the terminal. This works fine for standard alphanumerical characters, but
|
||||
most other characters require terminal specific control sequences.
|
||||
|
||||
The conversion below works for TERM="linux" terminals.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
# special case for MacOS
|
||||
# /!\ Qt maps ControlModifier to CMD
|
||||
# CMD-C, CMD-V for copy/paste
|
||||
# CTRL-C and other modifiers -> key mapping
|
||||
if event.modifiers() == QtCore.Qt.MetaModifier:
|
||||
if event.key() == Qt.Key_Backspace:
|
||||
return control_keys_mapping.get(Qt.Key_W)
|
||||
return control_keys_mapping.get(event.key())
|
||||
elif event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
if event.key() == Qt.Key_C:
|
||||
# copy
|
||||
return "copy"
|
||||
elif event.key() == Qt.Key_V:
|
||||
# paste
|
||||
return "paste"
|
||||
return None
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
if event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
return control_keys_mapping.get(event.key())
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
|
||||
|
||||
class Screen(pyte.HistoryScreen):
|
||||
def __init__(self, stdin_fd, cols, rows, historyLength):
|
||||
super().__init__(cols, rows, historyLength, ratio=1 / rows)
|
||||
self._fd = stdin_fd
|
||||
|
||||
def write_process_input(self, data):
|
||||
"""Response to CPR request (for example),
|
||||
this can be for other requests
|
||||
"""
|
||||
try:
|
||||
os.write(self._fd, data.encode("utf-8"))
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
def resize(self, lines, columns):
|
||||
lines = lines or self.lines
|
||||
columns = columns or self.columns
|
||||
|
||||
if lines == self.lines and columns == self.columns:
|
||||
return # No changes.
|
||||
|
||||
self.dirty.clear()
|
||||
self.dirty.update(range(lines))
|
||||
|
||||
self.save_cursor()
|
||||
if lines < self.lines:
|
||||
if lines <= self.cursor.y:
|
||||
nlines_to_move_up = self.lines - lines
|
||||
for i in range(nlines_to_move_up):
|
||||
line = self.buffer[i] # .pop(0)
|
||||
self.history.top.append(line)
|
||||
self.cursor_position(0, 0)
|
||||
self.delete_lines(nlines_to_move_up)
|
||||
self.restore_cursor()
|
||||
self.cursor.y -= nlines_to_move_up
|
||||
else:
|
||||
self.restore_cursor()
|
||||
|
||||
self.lines, self.columns = lines, columns
|
||||
self.history = History(
|
||||
self.history.top,
|
||||
self.history.bottom,
|
||||
1 / self.lines,
|
||||
self.history.size,
|
||||
self.history.position,
|
||||
)
|
||||
self.set_margins()
|
||||
|
||||
|
||||
class Backend(QtCore.QObject):
|
||||
"""
|
||||
Poll Bash.
|
||||
|
||||
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
|
||||
file descriptor of the Bash terminal.
|
||||
"""
|
||||
|
||||
# Signals to communicate with ``_TerminalWidget``.
|
||||
dataReady = pyqtSignal(object)
|
||||
processExited = pyqtSignal()
|
||||
|
||||
def __init__(self, fd, cols, rows):
|
||||
super().__init__()
|
||||
|
||||
# File descriptor that connects to Bash process.
|
||||
self.fd = fd
|
||||
|
||||
# Setup Pyte (hard coded display size for now).
|
||||
self.screen = Screen(self.fd, cols, rows, 10000)
|
||||
self.stream = pyte.ByteStream()
|
||||
self.stream.attach(self.screen)
|
||||
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._fd_readable)
|
||||
|
||||
def _fd_readable(self):
|
||||
"""
|
||||
Poll the Bash output, run it through Pyte, and notify
|
||||
"""
|
||||
# Read the shell output until the file descriptor is closed.
|
||||
try:
|
||||
out = os.read(self.fd, 2**16)
|
||||
except OSError:
|
||||
self.processExited.emit()
|
||||
self.notifier.setEnabled(False)
|
||||
return
|
||||
|
||||
# Feed output into Pyte's state machine and send the new screen
|
||||
# output to the GUI
|
||||
self.stream.feed(out)
|
||||
self.dataReady.emit(self.screen)
|
||||
|
||||
|
||||
class BECConsole(QtWidgets.QWidget):
|
||||
"""Container widget for the terminal text area"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
prompt = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None, cols=132):
|
||||
super().__init__(parent)
|
||||
|
||||
self.term = _TerminalWidget(self, cols, rows=43)
|
||||
self.term.prompt.connect(self.prompt) # forward signal from term to this widget
|
||||
|
||||
self.scroll_bar = QScrollBar(Qt.Vertical, self)
|
||||
# self.scroll_bar.hide()
|
||||
layout = QHBoxLayout(self)
|
||||
layout.addWidget(self.term)
|
||||
layout.addWidget(self.scroll_bar)
|
||||
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||||
|
||||
pal = QPalette()
|
||||
self.set_bgcolor(pal.window().color())
|
||||
self.set_fgcolor(pal.windowText().color())
|
||||
self.term.set_scroll_bar(self.scroll_bar)
|
||||
self.set_cmd("bec --nogui")
|
||||
|
||||
self._check_designer_timer = QTimer()
|
||||
self._check_designer_timer.timeout.connect(self.check_designer)
|
||||
self._check_designer_timer.start(1000)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
size = self.term.sizeHint()
|
||||
size.setWidth(size.width() + self.scroll_bar.width())
|
||||
return size
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSizeHint()
|
||||
|
||||
def check_designer(self, calls={"n": 0}):
|
||||
calls["n"] += 1
|
||||
if self.term.fd is not None:
|
||||
# already started
|
||||
self._check_designer_timer.stop()
|
||||
elif self.window().windowTitle().endswith("[Preview]"):
|
||||
# assuming Designer preview -> start
|
||||
self._check_designer_timer.stop()
|
||||
self.term.start()
|
||||
elif calls["n"] >= 3:
|
||||
# assuming not in Designer -> stop checking
|
||||
self._check_designer_timer.stop()
|
||||
|
||||
def get_rows(self):
|
||||
return self.term.rows
|
||||
|
||||
def set_rows(self, rows):
|
||||
self.term.rows = rows
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
|
||||
def get_cols(self):
|
||||
return self.term.cols
|
||||
|
||||
def set_cols(self, cols):
|
||||
self.term.cols = cols
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
|
||||
def get_bgcolor(self):
|
||||
return QColor.fromString(self.term.bg_color)
|
||||
|
||||
def set_bgcolor(self, color):
|
||||
self.term.bg_color = color.name(QColor.HexRgb)
|
||||
|
||||
def get_fgcolor(self):
|
||||
return QColor.fromString(self.term.fg_color)
|
||||
|
||||
def set_fgcolor(self, color):
|
||||
self.term.fg_color = color.name(QColor.HexRgb)
|
||||
|
||||
def get_cmd(self):
|
||||
return self.term._cmd
|
||||
|
||||
def set_cmd(self, cmd):
|
||||
self.term._cmd = cmd
|
||||
if self.term.fd is None:
|
||||
# not started yet
|
||||
self.term.clear()
|
||||
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
|
||||
|
||||
def start(self, deactivate_ctrl_d=True):
|
||||
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
|
||||
|
||||
def push(self, text, hit_return=False):
|
||||
"""Push some text to the terminal"""
|
||||
return self.term.push(text, hit_return=hit_return)
|
||||
|
||||
def execute_command(self, command):
|
||||
self.push(command, hit_return=True)
|
||||
|
||||
def set_prompt_tokens(self, *tokens):
|
||||
"""Prepare regexp to identify prompt, based on tokens
|
||||
|
||||
Tokens are returned from get_ipython().prompts.in_prompt_tokens()
|
||||
"""
|
||||
regex_parts = []
|
||||
for token_type, token_value in tokens:
|
||||
if token_type == Token.PromptNum: # Handle dynamic prompt number
|
||||
regex_parts.append(r"[\d\?]+") # Match one or more digits or '?'
|
||||
else:
|
||||
# Escape other prompt parts (e.g., "In [", "]: ")
|
||||
if not token_value:
|
||||
regex_parts.append(".+?") # arbitrary string
|
||||
else:
|
||||
regex_parts.append(re.escape(token_value))
|
||||
|
||||
# Combine into a single regex
|
||||
prompt_pattern = "".join(regex_parts)
|
||||
self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")
|
||||
|
||||
def terminate(self, timeout=10):
|
||||
self.term.stop(timeout=timeout)
|
||||
|
||||
def send_ctrl_c(self, timeout=None):
|
||||
self.term.send_ctrl_c(timeout)
|
||||
|
||||
cols = pyqtProperty(int, get_cols, set_cols)
|
||||
rows = pyqtProperty(int, get_rows, set_rows)
|
||||
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
|
||||
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
|
||||
cmd = pyqtProperty(str, get_cmd, set_cmd)
|
||||
|
||||
|
||||
class _TerminalWidget(QtWidgets.QPlainTextEdit):
|
||||
"""
|
||||
Start ``Backend`` process and render Pyte output as text.
|
||||
"""
|
||||
|
||||
prompt = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent, cols=125, rows=50, **kwargs):
|
||||
# regexp to match prompt
|
||||
self._prompt_re = None
|
||||
# last prompt
|
||||
self._prompt_str = None
|
||||
# process pid
|
||||
self.pid = None
|
||||
# file descriptor to communicate with the subprocess
|
||||
self.fd = None
|
||||
self.backend = None
|
||||
# command to execute
|
||||
self._cmd = ""
|
||||
# should ctrl-d be deactivated ? (prevent Python exit)
|
||||
self._deactivate_ctrl_d = False
|
||||
|
||||
# Default colors
|
||||
pal = QPalette()
|
||||
self._fg_color = pal.text().color().name()
|
||||
self._bg_color = pal.base().color().name()
|
||||
|
||||
# Specify the terminal size in terms of lines and columns.
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
self.output = collections.deque()
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
|
||||
|
||||
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.scroll_bar = None
|
||||
|
||||
# Use Monospace fonts and disable line wrapping.
|
||||
self.setFont(QtGui.QFont("Courier", 9))
|
||||
self.setFont(QtGui.QFont("Monospace"))
|
||||
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
self.setCursorWidth(char_width)
|
||||
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
self.update_stylesheet()
|
||||
|
||||
@property
|
||||
def bg_color(self):
|
||||
return self._bg_color
|
||||
|
||||
@bg_color.setter
|
||||
def bg_color(self, hexcolor):
|
||||
self._bg_color = hexcolor
|
||||
self.update_stylesheet()
|
||||
|
||||
@property
|
||||
def fg_color(self):
|
||||
return self._fg_color
|
||||
|
||||
@fg_color.setter
|
||||
def fg_color(self, hexcolor):
|
||||
self._fg_color = hexcolor
|
||||
self.update_stylesheet()
|
||||
|
||||
def update_stylesheet(self):
|
||||
self.setStyleSheet(
|
||||
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
|
||||
)
|
||||
|
||||
@property
|
||||
def rows(self):
|
||||
return self._rows
|
||||
|
||||
@rows.setter
|
||||
def rows(self, rows: int):
|
||||
if self.backend is None:
|
||||
# not initialized yet, ok to change
|
||||
self._rows = rows
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
else:
|
||||
raise RuntimeError("Cannot change rows after console is started.")
|
||||
|
||||
@property
|
||||
def cols(self):
|
||||
return self._cols
|
||||
|
||||
@cols.setter
|
||||
def cols(self, cols: int):
|
||||
if self.fd is None:
|
||||
# not initialized yet, ok to change
|
||||
self._cols = cols
|
||||
self.adjustSize()
|
||||
self.updateGeometry()
|
||||
else:
|
||||
raise RuntimeError("Cannot change cols after console is started.")
|
||||
|
||||
def start(self, deactivate_ctrl_d: bool = False):
|
||||
self._deactivate_ctrl_d = deactivate_ctrl_d
|
||||
|
||||
self.update_term_size()
|
||||
|
||||
# Start the Bash process
|
||||
self.pid, self.fd = self.fork_shell()
|
||||
|
||||
if self.fd:
|
||||
# Create the ``Backend`` object
|
||||
self.backend = Backend(self.fd, self.cols, self.rows)
|
||||
self.backend.dataReady.connect(self.data_ready)
|
||||
self.backend.processExited.connect(self.process_exited)
|
||||
else:
|
||||
self.process_exited()
|
||||
|
||||
def process_exited(self):
|
||||
self.fd = None
|
||||
self.clear()
|
||||
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
|
||||
self.setReadOnly(True)
|
||||
|
||||
def send_ctrl_c(self, wait_prompt=True, timeout=None):
|
||||
"""Send CTRL-C to the process
|
||||
|
||||
If wait_prompt=True (default), wait for a new prompt after CTRL-C
|
||||
If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
|
||||
"""
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
if wait_prompt:
|
||||
timeout_error = False
|
||||
if timeout:
|
||||
|
||||
def set_timeout_error():
|
||||
nonlocal timeout_error
|
||||
timeout_error = True
|
||||
|
||||
timeout_timer = QTimer()
|
||||
timeout_timer.singleShot(timeout * 1000, set_timeout_error)
|
||||
while self._prompt_str is None:
|
||||
QApplication.instance().process_events()
|
||||
if timeout_error:
|
||||
raise TimeoutError(
|
||||
f"CTRL-C: could not get back to prompt after {timeout} seconds."
|
||||
)
|
||||
|
||||
def _is_running(self):
|
||||
if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
def stop(self, kill=True, timeout=None):
|
||||
"""Stop the running process
|
||||
|
||||
SIGTERM is the default signal for terminating processes.
|
||||
|
||||
If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
|
||||
"""
|
||||
# try to exit gracefully
|
||||
os.kill(self.pid, signal.SIGTERM)
|
||||
|
||||
# wait until process is truly dead
|
||||
t0 = time.perf_counter()
|
||||
while self._is_running():
|
||||
time.sleep(1)
|
||||
if timeout is not None and time.perf_counter() - t0 > timeout:
|
||||
# still alive after 'timeout' seconds
|
||||
if kill:
|
||||
# send SIGKILL and make a last check in loop
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
kill = False
|
||||
else:
|
||||
# still running after timeout...
|
||||
raise TimeoutError(
|
||||
f"Could not terminate process with pid: {self.pid} within timeout"
|
||||
)
|
||||
self.process_exited()
|
||||
|
||||
def data_ready(self, screen):
|
||||
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
|
||||
|
||||
This method is triggered via a signal from ``Backend``.
|
||||
"""
|
||||
self.redraw_screen()
|
||||
self.adjust_scroll_bar()
|
||||
self.move_cursor()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
"""Return minimum size for current cols and rows"""
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
char_height = fmt.height()
|
||||
width = char_width * self.cols
|
||||
height = char_height * self.rows
|
||||
return QSize(width, height)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSizeHint()
|
||||
|
||||
def set_scroll_bar(self, scroll_bar):
|
||||
self.scroll_bar = scroll_bar
|
||||
self.scroll_bar.setMinimum(0)
|
||||
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def scroll_value_change(self, value, old={"value": -1}):
|
||||
if self.backend is None:
|
||||
return
|
||||
if old["value"] == -1:
|
||||
old["value"] = self.scroll_bar.maximum()
|
||||
if value <= old["value"]:
|
||||
# scroll up
|
||||
# value is number of lines from the start
|
||||
nlines = old["value"] - value
|
||||
# history ratio gives prev_page == 1 line
|
||||
for i in range(nlines):
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
# scroll down
|
||||
nlines = value - old["value"]
|
||||
for i in range(nlines):
|
||||
self.backend.screen.next_page()
|
||||
old["value"] = value
|
||||
self.redraw_screen()
|
||||
|
||||
def adjust_scroll_bar(self):
|
||||
sb = self.scroll_bar
|
||||
sb.valueChanged.disconnect(self.scroll_value_change)
|
||||
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
|
||||
sb.setMaximum(tmp if tmp > 0 else 0)
|
||||
sb.setSliderPosition(tmp if tmp > 0 else 0)
|
||||
# if tmp > 0:
|
||||
# # show scrollbar, but delayed - prevent recursion with widget size change
|
||||
# QTimer.singleShot(0, scrollbar.show)
|
||||
# else:
|
||||
# QTimer.singleShot(0, scrollbar.hide)
|
||||
sb.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def write(self, data):
|
||||
try:
|
||||
os.write(self.fd, data)
|
||||
except (IOError, OSError):
|
||||
self.process_exited()
|
||||
|
||||
@Slot(object)
|
||||
def keyPressEvent(self, event):
|
||||
"""
|
||||
Redirect all keystrokes to the terminal process.
|
||||
"""
|
||||
if self.fd is None:
|
||||
# not started
|
||||
return
|
||||
# Convert the Qt key to the correct ASCII code.
|
||||
if (
|
||||
self._deactivate_ctrl_d
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
and event.key() == QtCore.Qt.Key_D
|
||||
):
|
||||
return None
|
||||
|
||||
code = QtKeyToAscii(event)
|
||||
if code == "copy":
|
||||
# MacOS only: CMD-C handling
|
||||
self.copy()
|
||||
elif code == "paste":
|
||||
# MacOS only: CMD-V handling
|
||||
self._push_clipboard()
|
||||
elif code is not None:
|
||||
self.write(code)
|
||||
|
||||
def push(self, text, hit_return=False):
|
||||
"""
|
||||
Write 'text' to terminal
|
||||
"""
|
||||
self.write(text.encode("utf-8"))
|
||||
if hit_return:
|
||||
self.write(b"\n")
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
if self.fd is None:
|
||||
return
|
||||
menu = self.createStandardContextMenu()
|
||||
for action in menu.actions():
|
||||
# remove all actions except copy and paste
|
||||
if "opy" in action.text():
|
||||
# redefine text without shortcut
|
||||
# since it probably clashes with control codes (like CTRL-C etc)
|
||||
action.setText("Copy")
|
||||
continue
|
||||
if "aste" in action.text():
|
||||
# redefine text without shortcut
|
||||
action.setText("Paste")
|
||||
# paste -> have to insert with self.push
|
||||
action.triggered.connect(self._push_clipboard)
|
||||
continue
|
||||
menu.removeAction(action)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _push_clipboard(self):
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
self.push(clipboard.text())
|
||||
|
||||
def move_cursor(self):
|
||||
textCursor = self.textCursor()
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
|
||||
)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
|
||||
)
|
||||
self.setTextCursor(textCursor)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self.fd is None:
|
||||
return
|
||||
if event.button() == Qt.MiddleButton:
|
||||
# push primary selection buffer ("mouse clipboard") to terminal
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
if clipboard.supportsSelection():
|
||||
self.push(clipboard.text(QClipboard.Selection))
|
||||
return None
|
||||
elif event.button() == Qt.LeftButton:
|
||||
# left button click
|
||||
textCursor = self.textCursor()
|
||||
if textCursor.selectedText():
|
||||
# mouse was used to select text -> nothing to do
|
||||
pass
|
||||
else:
|
||||
# a simple 'click', move scrollbar to end
|
||||
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
|
||||
self.move_cursor()
|
||||
return None
|
||||
return super().mouseReleaseEvent(event)
|
||||
|
||||
def redraw_screen(self):
|
||||
"""
|
||||
Render the screen as formatted text into the widget.
|
||||
"""
|
||||
screen = self.backend.screen
|
||||
|
||||
# Clear the widget
|
||||
if screen.dirty:
|
||||
self.clear()
|
||||
while len(self.output) < (max(screen.dirty) + 1):
|
||||
self.output.append("")
|
||||
while len(self.output) > (max(screen.dirty) + 1):
|
||||
self.output.pop()
|
||||
|
||||
# Prepare the HTML output
|
||||
for line_no in screen.dirty:
|
||||
line = text = ""
|
||||
style = old_style = ""
|
||||
old_idx = 0
|
||||
for idx, ch in screen.buffer[line_no].items():
|
||||
text += " " * (idx - old_idx - 1)
|
||||
old_idx = idx
|
||||
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
|
||||
if style != old_style:
|
||||
if old_style:
|
||||
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
text = ""
|
||||
old_style = style
|
||||
text += ch.data
|
||||
if style:
|
||||
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
# do a check at the cursor position:
|
||||
# it is possible x pos > output line length,
|
||||
# for example if last escape codes are "cursor forward" past end of text,
|
||||
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
|
||||
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
|
||||
if line_no == screen.cursor.y:
|
||||
llen = len(screen.buffer[line_no])
|
||||
if llen < screen.cursor.x:
|
||||
line += " " * (screen.cursor.x - llen)
|
||||
self.output[line_no] = line
|
||||
# fill the text area with HTML contents in one go
|
||||
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
|
||||
|
||||
if self._prompt_re is not None:
|
||||
text_buf = self.toPlainText()
|
||||
prompt = self._prompt_re.search(text_buf)
|
||||
if prompt is None:
|
||||
if self._prompt_str:
|
||||
self.prompt.emit(False)
|
||||
self._prompt_str = None
|
||||
else:
|
||||
prompt_str = prompt.string.rstrip()
|
||||
if prompt_str != self._prompt_str:
|
||||
self._prompt_str = prompt_str
|
||||
self.prompt.emit(True)
|
||||
|
||||
# did updates, all clean
|
||||
screen.dirty.clear()
|
||||
|
||||
def update_term_size(self):
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
char_height = fmt.height()
|
||||
self._cols = int(self.width() / char_width)
|
||||
self._rows = int(self.height() / char_height)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self.update_term_size()
|
||||
if self.fd:
|
||||
self.backend.screen.resize(self._rows, self._cols)
|
||||
self.redraw_screen()
|
||||
self.adjust_scroll_bar()
|
||||
self.move_cursor()
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if not self.fd:
|
||||
return
|
||||
y = event.angleDelta().y()
|
||||
if y > 0:
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
self.backend.screen.next_page()
|
||||
self.redraw_screen()
|
||||
|
||||
def fork_shell(self):
|
||||
"""
|
||||
Fork the current process and execute bec in shell.
|
||||
"""
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
if pid == 0:
|
||||
try:
|
||||
ls = os.environ["LANG"].split(".")
|
||||
except KeyError:
|
||||
ls = []
|
||||
if len(ls) < 2:
|
||||
ls = ["en_US", "UTF-8"]
|
||||
os.putenv("COLUMNS", str(self.cols))
|
||||
os.putenv("LINES", str(self.rows))
|
||||
os.putenv("TERM", "linux")
|
||||
os.putenv("LANG", ls[0] + ".UTF-8")
|
||||
if not self._cmd:
|
||||
self._cmd = os.environ["SHELL"]
|
||||
cmd = self._cmd
|
||||
if isinstance(cmd, str):
|
||||
cmd = cmd.split()
|
||||
try:
|
||||
os.execvp(cmd[0], cmd)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
os._exit(0)
|
||||
else:
|
||||
# We are in the parent process.
|
||||
# Set file control
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
||||
return pid, fd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
# Create the Qt application and console.
|
||||
app = QtWidgets.QApplication([])
|
||||
mainwin = QtWidgets.QMainWindow()
|
||||
title = "BECConsole"
|
||||
mainwin.setWindowTitle(title)
|
||||
|
||||
console = BECConsole(mainwin)
|
||||
mainwin.setCentralWidget(console)
|
||||
|
||||
def check_prompt(at_prompt):
|
||||
if at_prompt:
|
||||
print("NEW PROMPT")
|
||||
else:
|
||||
print("EXECUTING SOMETHING...")
|
||||
|
||||
console.set_prompt_tokens(
|
||||
(Token.OutPromptNum, "•"),
|
||||
(Token.Prompt, ""), # will match arbitrary string,
|
||||
(Token.Prompt, " ["),
|
||||
(Token.PromptNum, "3"),
|
||||
(Token.Prompt, "/"),
|
||||
(Token.PromptNum, "1"),
|
||||
(Token.Prompt, "] "),
|
||||
(Token.Prompt, "❯❯"),
|
||||
)
|
||||
console.prompt.connect(check_prompt)
|
||||
console.start()
|
||||
|
||||
# Show widget and launch Qt's event loop.
|
||||
mainwin.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['console.py']}
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -45,7 +46,11 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole):
|
||||
if index.isValid():
|
||||
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
|
||||
if role in [
|
||||
Qt.ItemDataRole.DisplayRole,
|
||||
Qt.ItemDataRole.EditRole,
|
||||
Qt.ItemDataRole.ToolTipRole,
|
||||
]:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
|
||||
def setData(self, index, value, role):
|
||||
@@ -57,6 +62,11 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
return True
|
||||
return False
|
||||
|
||||
def replaceData(self, data: dict):
|
||||
self.resetInternalData()
|
||||
self._data = [[k, v] for k, v in data.items()]
|
||||
self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 0))
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
"""Set the list of keys which may not be used.
|
||||
|
||||
@@ -110,16 +120,16 @@ class DictBackedTableModel(QAbstractTableModel):
|
||||
|
||||
class DictBackedTable(QWidget):
|
||||
delete_rows = Signal(list)
|
||||
data_updated = Signal()
|
||||
data_changed = Signal(dict)
|
||||
|
||||
def __init__(self, initial_data: list[list[str]]):
|
||||
def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
|
||||
"""Widget which uses a DictBackedTableModel to display an editable table
|
||||
which can be extracted as a dict.
|
||||
|
||||
Args:
|
||||
initial_data (list[list[str]]): list of key-value pairs to initialise with
|
||||
"""
|
||||
super().__init__()
|
||||
super().__init__(parent)
|
||||
|
||||
self._layout = QHBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
@@ -127,13 +137,17 @@ class DictBackedTable(QWidget):
|
||||
self._table_view = QTreeView()
|
||||
self._table_view.setModel(self._table_model)
|
||||
self._table_view.setSizePolicy(
|
||||
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
self._table_view.setAlternatingRowColors(True)
|
||||
self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
||||
self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
|
||||
self._layout.addWidget(self._table_view)
|
||||
|
||||
self._button_holder = QWidget()
|
||||
self._buttons = QVBoxLayout()
|
||||
self._layout.addLayout(self._buttons)
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
@@ -143,11 +157,17 @@ class DictBackedTable(QWidget):
|
||||
self._add_button.clicked.connect(self._table_model.add_row)
|
||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||
self.delete_rows.connect(self._table_model.delete_rows)
|
||||
self._table_model.dataChanged.connect(self._emit_data_updated)
|
||||
self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
|
||||
|
||||
def _emit_data_updated(self, *args, **kwargs):
|
||||
"""Just to swallow the args"""
|
||||
self.data_updated.emit()
|
||||
def set_button_visibility(self, value: bool):
|
||||
self._button_holder.setVisible(value)
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self):
|
||||
self._table_model.replaceData({})
|
||||
|
||||
def replace_data(self, data: dict):
|
||||
self._table_model.replaceData(data)
|
||||
|
||||
def delete_selected_rows(self):
|
||||
"""Delete rows which are part of the selection model"""
|
||||
@@ -174,6 +194,6 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
|
||||
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from math import inf, nextafter
|
||||
from math import copysign, inf, nextafter
|
||||
from typing import TYPE_CHECKING, TypeVar, get_args
|
||||
|
||||
from annotated_types import Ge, Gt, Le, Lt
|
||||
@@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
|
||||
T = TypeVar("T", int, float, Decimal)
|
||||
|
||||
|
||||
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
|
||||
def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]:
|
||||
def _nextafter(x, y):
|
||||
return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
|
||||
|
||||
_min = _MININT if type_ is int else _MINFLOAT
|
||||
_max = _MAXINT if type_ is int else _MAXFLOAT
|
||||
for md in info.metadata:
|
||||
if isinstance(md, Ge):
|
||||
_min = type_(md.ge) # type: ignore
|
||||
if isinstance(md, Gt):
|
||||
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
|
||||
_min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore
|
||||
if isinstance(md, Lt):
|
||||
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
|
||||
_max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore
|
||||
if isinstance(md, Le):
|
||||
_max = type_(md.le) # type: ignore
|
||||
return _min, _max # type: ignore
|
||||
|
||||
@@ -16,6 +16,9 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanMetadata(PydanticModelForm):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
@@ -36,16 +39,18 @@ class ScanMetadata(PydanticModelForm):
|
||||
|
||||
# self.populate() gets called in super().__init__
|
||||
# so make sure self._additional_metadata exists
|
||||
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
|
||||
self._additional_md_box = ExpandableGroupFrame(
|
||||
parent, "Additional metadata", expanded=False
|
||||
)
|
||||
self._additional_md_box_layout = QHBoxLayout()
|
||||
self._additional_md_box.set_layout(self._additional_md_box_layout)
|
||||
|
||||
self._additional_metadata = DictBackedTable(initial_extras or [])
|
||||
self._additional_metadata = DictBackedTable(parent, 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)
|
||||
self._additional_metadata.data_changed.connect(self.validate_form)
|
||||
|
||||
super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
|
||||
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
|
||||
|
||||
self._layout.addWidget(self._additional_md_box)
|
||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||
@@ -127,6 +132,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
w.setLayout(layout)
|
||||
|
||||
scan_metadata = ScanMetadata(
|
||||
parent=w,
|
||||
scan_name="grid_scan",
|
||||
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
|
||||
)
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
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 pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtWidgets import 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.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
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
|
||||
|
||||
@@ -46,7 +34,15 @@ class ImageConfig(ConnectionConfig):
|
||||
_validate_color_map = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
class Image(PlotBase):
|
||||
class ImageLayerConfig(BaseModel):
|
||||
monitor: str | None = Field(None, description="The name of the monitor.")
|
||||
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
|
||||
source: Literal["device_monitor_1d", "device_monitor_2d", "auto"] = Field(
|
||||
"auto", description="The source of the image data."
|
||||
)
|
||||
|
||||
|
||||
class Image(ImageBase):
|
||||
"""
|
||||
Image widget for displaying 2D data.
|
||||
"""
|
||||
@@ -85,11 +81,13 @@ class Image(PlotBase):
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
"vrange",
|
||||
"vrange.setter",
|
||||
"v_range",
|
||||
"v_range.setter",
|
||||
"v_min",
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
@@ -121,7 +119,6 @@ class Image(PlotBase):
|
||||
"remove_roi",
|
||||
"rois",
|
||||
]
|
||||
sync_colorbar_with_autorange = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -136,439 +133,15 @@ class Image(PlotBase):
|
||||
config = ImageConfig(widget_class=self.__class__.__name__)
|
||||
self.gui_id = config.gui_id
|
||||
self._color_bar = None
|
||||
self._main_image = ImageItem()
|
||||
self.roi_controller = ROIController(colormap="viridis")
|
||||
self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(
|
||||
lambda: ImageLayerConfig(monitor=None, monitor_type="auto", source="auto")
|
||||
)
|
||||
super().__init__(
|
||||
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
|
||||
)
|
||||
self._main_image = ImageItem(parent_image=self)
|
||||
|
||||
self.plot_item.addItem(self._main_image)
|
||||
self.layer_removed.connect(self._on_layer_removed)
|
||||
self.scan_id = None
|
||||
|
||||
# Default Color map to plasma
|
||||
self.color_map = "plasma"
|
||||
|
||||
# Headless controller keeps the canonical list.
|
||||
self._roi_manager_dialog = None
|
||||
|
||||
################################################################################
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
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)
|
||||
|
||||
super()._init_toolbar()
|
||||
|
||||
# Image specific changes to PlotBase toolbar
|
||||
self.toolbar.widgets["reset_legend"].action.setVisible(False)
|
||||
|
||||
# Lock aspect ratio button
|
||||
self.lock_aspect_ratio_action = MaterialIconAction(
|
||||
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="mouse_interaction",
|
||||
action_id="lock_aspect_ratio",
|
||||
action=self.lock_aspect_ratio_action,
|
||||
target_widget=self,
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.toggled.connect(
|
||||
lambda checked: self.setProperty("lock_aspect_ratio", checked)
|
||||
)
|
||||
self.lock_aspect_ratio_action.action.setChecked(True)
|
||||
|
||||
self._init_autorange_action()
|
||||
self._init_colorbar_action()
|
||||
|
||||
# Processing Bundle
|
||||
self.processing_bundle = ImageProcessingToolbarBundle(
|
||||
bundle_id="processing", target_widget=self
|
||||
)
|
||||
self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
|
||||
|
||||
def _init_autorange_action(self):
|
||||
|
||||
self.autorange_mean_action = MaterialIconAction(
|
||||
icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
|
||||
)
|
||||
self.autorange_max_action = MaterialIconAction(
|
||||
icon_name="hdr_auto",
|
||||
tooltip="Enable Auto Range (Max)",
|
||||
checkable=True,
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.autorange_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"auto_range_mean": self.autorange_mean_action,
|
||||
"auto_range_max": self.autorange_max_action,
|
||||
},
|
||||
initial_action="auto_range_mean",
|
||||
tooltip="Enable Auto Range",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="autorange_image",
|
||||
action=self.autorange_switch,
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.autorange_mean_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="mean")
|
||||
)
|
||||
self.autorange_max_action.action.toggled.connect(
|
||||
lambda checked: self.toggle_autorange(checked, mode="max")
|
||||
)
|
||||
|
||||
self.autorange = True
|
||||
self.autorange_mode = "mean"
|
||||
|
||||
def _init_colorbar_action(self):
|
||||
self.full_colorbar_action = MaterialIconAction(
|
||||
icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
|
||||
)
|
||||
self.simple_colorbar_action = MaterialIconAction(
|
||||
icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
|
||||
)
|
||||
|
||||
self.colorbar_switch = SwitchableToolBarAction(
|
||||
actions={
|
||||
"full_colorbar": self.full_colorbar_action,
|
||||
"simple_colorbar": self.simple_colorbar_action,
|
||||
},
|
||||
initial_action="full_colorbar",
|
||||
tooltip="Enable Full Colorbar",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.toolbar.add_action_to_bundle(
|
||||
bundle_id="roi",
|
||||
action_id="switch_colorbar",
|
||||
action=self.colorbar_switch,
|
||||
target_widget=self,
|
||||
)
|
||||
|
||||
self.simple_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="simple")
|
||||
)
|
||||
self.full_colorbar_action.action.toggled.connect(
|
||||
lambda checked: self.enable_colorbar(checked, style="full")
|
||||
)
|
||||
|
||||
def enable_colorbar(
|
||||
self,
|
||||
enabled: bool,
|
||||
style: Literal["full", "simple"] = "full",
|
||||
vrange: tuple[int, int] | None = None,
|
||||
):
|
||||
"""
|
||||
Enable the colorbar and switch types of colorbars.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable the colorbar.
|
||||
style(Literal["full", "simple"]): The type of colorbar to enable.
|
||||
vrange(tuple): The range of values to use for the colorbar.
|
||||
"""
|
||||
autorange_state = self._main_image.autorange
|
||||
if enabled:
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "full":
|
||||
self.cleanup_histogram_lut_item(self._color_bar)
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar = None
|
||||
|
||||
if style == "simple":
|
||||
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
|
||||
self._color_bar.setImageItem(self._main_image)
|
||||
self._color_bar.sigLevelsChangeFinished.connect(
|
||||
lambda: self.setProperty("autorange", False)
|
||||
)
|
||||
|
||||
elif style == "full":
|
||||
self._color_bar = pg.HistogramLUTItem()
|
||||
self._color_bar.setImageItem(self._main_image)
|
||||
self._color_bar.gradient.loadPreset(self.config.color_map)
|
||||
self._color_bar.sigLevelsChanged.connect(
|
||||
lambda: self.setProperty("autorange", False)
|
||||
)
|
||||
|
||||
self.plot_widget.addItem(self._color_bar, row=0, col=1)
|
||||
self.config.color_bar = style
|
||||
else:
|
||||
if self._color_bar:
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar = None
|
||||
self.config.color_bar = None
|
||||
|
||||
self.autorange = autorange_state
|
||||
self._sync_colorbar_actions()
|
||||
|
||||
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 = 10,
|
||||
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)
|
||||
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")
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
################################################################################
|
||||
################################################################################
|
||||
# Rois
|
||||
|
||||
@property
|
||||
def rois(self) -> list[BaseROI]:
|
||||
"""
|
||||
Get the list of ROIs.
|
||||
"""
|
||||
return self.roi_controller.rois
|
||||
|
||||
################################################################################
|
||||
# Colorbar toggle
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_simple_colorbar(self) -> bool:
|
||||
"""
|
||||
Enable the simple colorbar.
|
||||
"""
|
||||
enabled = False
|
||||
if self.config.color_bar == "simple":
|
||||
enabled = True
|
||||
return enabled
|
||||
|
||||
@enable_simple_colorbar.setter
|
||||
def enable_simple_colorbar(self, value: bool):
|
||||
"""
|
||||
Enable the simple colorbar.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to enable the simple colorbar.
|
||||
"""
|
||||
self.enable_colorbar(enabled=value, style="simple")
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_full_colorbar(self) -> bool:
|
||||
"""
|
||||
Enable the full colorbar.
|
||||
"""
|
||||
enabled = False
|
||||
if self.config.color_bar == "full":
|
||||
enabled = True
|
||||
return enabled
|
||||
|
||||
@enable_full_colorbar.setter
|
||||
def enable_full_colorbar(self, value: bool):
|
||||
"""
|
||||
Enable the full colorbar.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to enable the full colorbar.
|
||||
"""
|
||||
self.enable_colorbar(enabled=value, style="full")
|
||||
|
||||
################################################################################
|
||||
# Appearance
|
||||
|
||||
@SafeProperty(str)
|
||||
def color_map(self) -> str:
|
||||
"""
|
||||
Set the color map of the image.
|
||||
"""
|
||||
return self.config.color_map
|
||||
|
||||
@color_map.setter
|
||||
def color_map(self, value: str):
|
||||
"""
|
||||
Set the color map of the image.
|
||||
|
||||
Args:
|
||||
value(str): The color map to set.
|
||||
"""
|
||||
try:
|
||||
self.config.color_map = value
|
||||
self._main_image.color_map = value
|
||||
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "simple":
|
||||
self._color_bar.setColorMap(value)
|
||||
elif self.config.color_bar == "full":
|
||||
self._color_bar.gradient.loadPreset(value)
|
||||
except ValidationError:
|
||||
return
|
||||
|
||||
# v_range is for designer, vrange is for RPC
|
||||
@SafeProperty("QPointF")
|
||||
def v_range(self) -> QPointF:
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
"""
|
||||
vmin, vmax = self._main_image.v_range
|
||||
return QPointF(vmin, vmax)
|
||||
|
||||
@v_range.setter
|
||||
def v_range(self, value: tuple | list | QPointF):
|
||||
"""
|
||||
Set the v_range of the main image.
|
||||
|
||||
Args:
|
||||
value(tuple | list | QPointF): The range of values to set.
|
||||
"""
|
||||
if isinstance(value, (tuple, list)):
|
||||
value = self._tuple_to_qpointf(value)
|
||||
|
||||
vmin, vmax = value.x(), value.y()
|
||||
|
||||
self._main_image.v_range = (vmin, vmax)
|
||||
|
||||
# propagate to colorbar if exists
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "simple":
|
||||
self._color_bar.setLevels(low=vmin, high=vmax)
|
||||
elif self.config.color_bar == "full":
|
||||
self._color_bar.setLevels(min=vmin, max=vmax)
|
||||
self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
|
||||
|
||||
self.autorange_switch.set_state_all(False)
|
||||
|
||||
@property
|
||||
def vrange(self) -> tuple:
|
||||
"""
|
||||
Get the vrange of the image.
|
||||
"""
|
||||
return (self.v_range.x(), self.v_range.y())
|
||||
|
||||
@vrange.setter
|
||||
def vrange(self, value):
|
||||
"""
|
||||
Set the vrange of the image.
|
||||
|
||||
Args:
|
||||
value(tuple):
|
||||
"""
|
||||
self.v_range = value
|
||||
|
||||
@property
|
||||
def v_min(self) -> float:
|
||||
"""
|
||||
Get the minimum value of the v_range.
|
||||
"""
|
||||
return self.v_range.x()
|
||||
|
||||
@v_min.setter
|
||||
def v_min(self, value: float):
|
||||
"""
|
||||
Set the minimum value of the v_range.
|
||||
|
||||
Args:
|
||||
value(float): The minimum value to set.
|
||||
"""
|
||||
self.v_range = (value, self.v_range.y())
|
||||
|
||||
@property
|
||||
def v_max(self) -> float:
|
||||
"""
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
return self.v_range.y()
|
||||
|
||||
@v_max.setter
|
||||
def v_max(self, value: float):
|
||||
"""
|
||||
Set the maximum value of the v_range.
|
||||
|
||||
Args:
|
||||
value(float): The maximum value to set.
|
||||
"""
|
||||
self.v_range = (self.v_range.x(), value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def lock_aspect_ratio(self) -> bool:
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
return self.config.lock_aspect_ratio
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
def lock_aspect_ratio(self, value: bool):
|
||||
"""
|
||||
Set the aspect ratio lock.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to lock the aspect ratio.
|
||||
"""
|
||||
self.config.lock_aspect_ratio = bool(value)
|
||||
self.plot_item.setAspectLocked(value)
|
||||
|
||||
################################################################################
|
||||
# Data Acquisition
|
||||
|
||||
@@ -577,7 +150,7 @@ class Image(PlotBase):
|
||||
"""
|
||||
The name of the monitor to use for the image.
|
||||
"""
|
||||
return self._main_image.config.monitor
|
||||
return self.subscriptions["main"].monitor or ""
|
||||
|
||||
@monitor.setter
|
||||
def monitor(self, value: str):
|
||||
@@ -587,7 +160,7 @@ class Image(PlotBase):
|
||||
Args:
|
||||
value(str): The name of the monitor to set.
|
||||
"""
|
||||
if self._main_image.config.monitor == value:
|
||||
if self.subscriptions["main"].monitor == value:
|
||||
return
|
||||
try:
|
||||
self.entry_validator.validate_monitor(value)
|
||||
@@ -598,175 +171,7 @@ class Image(PlotBase):
|
||||
@property
|
||||
def main_image(self) -> ImageItem:
|
||||
"""Access the main image item."""
|
||||
return self._main_image
|
||||
|
||||
################################################################################
|
||||
# Autorange + Colorbar sync
|
||||
|
||||
@SafeProperty(bool)
|
||||
def autorange(self) -> bool:
|
||||
"""
|
||||
Whether autorange is enabled.
|
||||
"""
|
||||
return self._main_image.autorange
|
||||
|
||||
@autorange.setter
|
||||
def autorange(self, enabled: bool):
|
||||
"""
|
||||
Set autorange.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
"""
|
||||
self._main_image.autorange = enabled
|
||||
if enabled and self._main_image.raw_data is not None:
|
||||
self._main_image.apply_autorange()
|
||||
self._sync_colorbar_levels()
|
||||
self._sync_autorange_switch()
|
||||
|
||||
@SafeProperty(str)
|
||||
def autorange_mode(self) -> str:
|
||||
"""
|
||||
Autorange mode.
|
||||
|
||||
Options:
|
||||
- "max": Use the maximum value of the image for autoranging.
|
||||
- "mean": Use the mean value of the image for autoranging.
|
||||
|
||||
"""
|
||||
return self._main_image.autorange_mode
|
||||
|
||||
@autorange_mode.setter
|
||||
def autorange_mode(self, mode: str):
|
||||
"""
|
||||
Set the autorange mode.
|
||||
|
||||
Args:
|
||||
mode(str): The autorange mode. Options are "max" or "mean".
|
||||
"""
|
||||
# for qt Designer
|
||||
if mode not in ["max", "mean"]:
|
||||
return
|
||||
self._main_image.autorange_mode = mode
|
||||
|
||||
self._sync_autorange_switch()
|
||||
|
||||
@SafeSlot(bool, str, bool)
|
||||
def toggle_autorange(self, enabled: bool, mode: str):
|
||||
"""
|
||||
Toggle autorange.
|
||||
|
||||
Args:
|
||||
enabled(bool): Whether to enable autorange.
|
||||
mode(str): The autorange mode. Options are "max" or "mean".
|
||||
"""
|
||||
if self._main_image is not None:
|
||||
self._main_image.autorange = enabled
|
||||
self._main_image.autorange_mode = mode
|
||||
if enabled:
|
||||
self._main_image.apply_autorange()
|
||||
self._sync_colorbar_levels()
|
||||
|
||||
def _sync_autorange_switch(self):
|
||||
"""
|
||||
Synchronize the autorange switch with the current autorange state and mode if changed from outside.
|
||||
"""
|
||||
self.autorange_switch.block_all_signals(True)
|
||||
self.autorange_switch.set_default_action(f"auto_range_{self._main_image.autorange_mode}")
|
||||
self.autorange_switch.set_state_all(self._main_image.autorange)
|
||||
self.autorange_switch.block_all_signals(False)
|
||||
|
||||
def _sync_colorbar_levels(self):
|
||||
"""Immediately propagate current levels to the active colorbar."""
|
||||
vrange = self._main_image.v_range
|
||||
if self._color_bar:
|
||||
self._color_bar.blockSignals(True)
|
||||
self.v_range = vrange
|
||||
self._color_bar.blockSignals(False)
|
||||
|
||||
def _sync_colorbar_actions(self):
|
||||
"""
|
||||
Synchronize the colorbar actions with the current colorbar state.
|
||||
"""
|
||||
self.colorbar_switch.block_all_signals(True)
|
||||
if self._color_bar is not None:
|
||||
self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
|
||||
self.colorbar_switch.set_state_all(True)
|
||||
else:
|
||||
self.colorbar_switch.set_state_all(False)
|
||||
self.colorbar_switch.block_all_signals(False)
|
||||
|
||||
################################################################################
|
||||
# Post Processing
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(bool)
|
||||
def fft(self) -> bool:
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
"""
|
||||
return self._main_image.fft
|
||||
|
||||
@fft.setter
|
||||
def fft(self, enable: bool):
|
||||
"""
|
||||
Set FFT postprocessing.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable FFT postprocessing.
|
||||
"""
|
||||
self._main_image.fft = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
def log(self) -> bool:
|
||||
"""
|
||||
Whether logarithmic scaling is applied.
|
||||
"""
|
||||
return self._main_image.log
|
||||
|
||||
@log.setter
|
||||
def log(self, enable: bool):
|
||||
"""
|
||||
Set logarithmic scaling.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable logarithmic scaling.
|
||||
"""
|
||||
self._main_image.log = enable
|
||||
|
||||
@SafeProperty(int)
|
||||
def num_rotation_90(self) -> int:
|
||||
"""
|
||||
The number of 90° rotations to apply counterclockwise.
|
||||
"""
|
||||
return self._main_image.num_rotation_90
|
||||
|
||||
@num_rotation_90.setter
|
||||
def num_rotation_90(self, value: int):
|
||||
"""
|
||||
Set the number of 90° rotations to apply counterclockwise.
|
||||
|
||||
Args:
|
||||
value(int): The number of 90° rotations to apply.
|
||||
"""
|
||||
self._main_image.num_rotation_90 = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def transpose(self) -> bool:
|
||||
"""
|
||||
Whether the image is transposed.
|
||||
"""
|
||||
return self._main_image.transpose
|
||||
|
||||
@transpose.setter
|
||||
def transpose(self, enable: bool):
|
||||
"""
|
||||
Set the image to be transposed.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable transposing the image.
|
||||
"""
|
||||
self._main_image.transpose = enable
|
||||
return self.layer_manager["main"].image
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
@@ -794,27 +199,27 @@ class Image(PlotBase):
|
||||
ImageItem: The image object.
|
||||
"""
|
||||
|
||||
if self._main_image.config.monitor is not None:
|
||||
self.disconnect_monitor(self._main_image.config.monitor)
|
||||
if self.subscriptions["main"].monitor:
|
||||
self.disconnect_monitor(self.subscriptions["main"].monitor)
|
||||
self.entry_validator.validate_monitor(monitor)
|
||||
self._main_image.config.monitor = monitor
|
||||
self.subscriptions["main"].monitor = monitor
|
||||
|
||||
if monitor_type == "1d":
|
||||
self._main_image.config.source = "device_monitor_1d"
|
||||
self._main_image.config.monitor_type = "1d"
|
||||
self.subscriptions["main"].source = "device_monitor_1d"
|
||||
self.subscriptions["main"].monitor_type = "1d"
|
||||
elif monitor_type == "2d":
|
||||
self._main_image.config.source = "device_monitor_2d"
|
||||
self._main_image.config.monitor_type = "2d"
|
||||
self.subscriptions["main"].source = "device_monitor_2d"
|
||||
self.subscriptions["main"].monitor_type = "2d"
|
||||
elif monitor_type == "auto":
|
||||
self._main_image.config.source = "auto"
|
||||
self.subscriptions["main"].source = "auto"
|
||||
logger.warning(
|
||||
f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints."
|
||||
)
|
||||
self._main_image.config.monitor_type = "auto"
|
||||
self.subscriptions["main"].monitor_type = "auto"
|
||||
|
||||
self.set_image_update(monitor=monitor, type=monitor_type)
|
||||
if color_map is not None:
|
||||
self._main_image.color_map = color_map
|
||||
self.main_image.color_map = color_map
|
||||
if color_bar is not None:
|
||||
self.enable_colorbar(True, color_bar)
|
||||
if vrange is not None:
|
||||
@@ -822,20 +227,21 @@ class Image(PlotBase):
|
||||
|
||||
self._sync_device_selection()
|
||||
|
||||
return self._main_image
|
||||
return self.main_image
|
||||
|
||||
def _sync_device_selection(self):
|
||||
"""
|
||||
Synchronize the device selection with the current monitor.
|
||||
"""
|
||||
if self._main_image.config.monitor is not None:
|
||||
config = self.subscriptions["main"]
|
||||
if config.monitor is not None:
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
):
|
||||
combo.blockSignals(True)
|
||||
self.selection_bundle.device_combo_box.set_device(self._main_image.config.monitor)
|
||||
self.selection_bundle.dim_combo_box.setCurrentText(self._main_image.config.monitor_type)
|
||||
self.selection_bundle.device_combo_box.set_device(config.monitor)
|
||||
self.selection_bundle.dim_combo_box.setCurrentText(config.monitor_type)
|
||||
for combo in (
|
||||
self.selection_bundle.device_combo_box,
|
||||
self.selection_bundle.dim_combo_box,
|
||||
@@ -855,6 +261,78 @@ class Image(PlotBase):
|
||||
):
|
||||
combo.blockSignals(False)
|
||||
|
||||
################################################################################
|
||||
# Post Processing
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(bool)
|
||||
def fft(self) -> bool:
|
||||
"""
|
||||
Whether FFT postprocessing is enabled.
|
||||
"""
|
||||
return self.main_image.fft
|
||||
|
||||
@fft.setter
|
||||
def fft(self, enable: bool):
|
||||
"""
|
||||
Set FFT postprocessing.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable FFT postprocessing.
|
||||
"""
|
||||
self.main_image.fft = enable
|
||||
|
||||
@SafeProperty(bool)
|
||||
def log(self) -> bool:
|
||||
"""
|
||||
Whether logarithmic scaling is applied.
|
||||
"""
|
||||
return self.main_image.log
|
||||
|
||||
@log.setter
|
||||
def log(self, enable: bool):
|
||||
"""
|
||||
Set logarithmic scaling.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable logarithmic scaling.
|
||||
"""
|
||||
self.main_image.log = enable
|
||||
|
||||
@SafeProperty(int)
|
||||
def num_rotation_90(self) -> int:
|
||||
"""
|
||||
The number of 90° rotations to apply counterclockwise.
|
||||
"""
|
||||
return self.main_image.num_rotation_90
|
||||
|
||||
@num_rotation_90.setter
|
||||
def num_rotation_90(self, value: int):
|
||||
"""
|
||||
Set the number of 90° rotations to apply counterclockwise.
|
||||
|
||||
Args:
|
||||
value(int): The number of 90° rotations to apply.
|
||||
"""
|
||||
self.main_image.num_rotation_90 = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def transpose(self) -> bool:
|
||||
"""
|
||||
Whether the image is transposed.
|
||||
"""
|
||||
return self.main_image.transpose
|
||||
|
||||
@transpose.setter
|
||||
def transpose(self, enable: bool):
|
||||
"""
|
||||
Set the image to be transposed.
|
||||
|
||||
Args:
|
||||
enable(bool): Whether to enable transposing the image.
|
||||
"""
|
||||
self.main_image.transpose = enable
|
||||
|
||||
################################################################################
|
||||
# Image Update Methods
|
||||
################################################################################
|
||||
@@ -887,8 +365,8 @@ class Image(PlotBase):
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
print(f"Connected to {monitor} with type {type}")
|
||||
self._main_image.config.monitor = monitor
|
||||
logger.info(f"Connected to {monitor} with type {type}")
|
||||
self.subscriptions["main"].monitor = monitor
|
||||
|
||||
def disconnect_monitor(self, monitor: str):
|
||||
"""
|
||||
@@ -903,7 +381,7 @@ class Image(PlotBase):
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
|
||||
)
|
||||
self._main_image.config.monitor = None
|
||||
self.subscriptions["main"].monitor = None
|
||||
self._sync_device_selection()
|
||||
|
||||
########################################
|
||||
@@ -925,15 +403,16 @@ class Image(PlotBase):
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self.scan_id = current_scan_id
|
||||
self._main_image.clear()
|
||||
self._main_image.buffer = []
|
||||
self._main_image.max_len = 0
|
||||
image_buffer = self.adjust_image_buffer(self._main_image, data)
|
||||
self.main_image.clear()
|
||||
self.main_image.buffer = []
|
||||
self.main_image.max_len = 0
|
||||
image_buffer = self.adjust_image_buffer(self.main_image, data)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
self._main_image.set_data(image_buffer)
|
||||
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:
|
||||
"""
|
||||
@@ -982,69 +461,64 @@ class Image(PlotBase):
|
||||
data = msg["data"]
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
self._main_image.set_data(data)
|
||||
self.main_image.set_data(data)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
self.image_updated.emit()
|
||||
|
||||
################################################################################
|
||||
# Clean up
|
||||
################################################################################
|
||||
|
||||
@staticmethod
|
||||
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
|
||||
@SafeSlot(str)
|
||||
def _on_layer_removed(self, layer_name: str):
|
||||
"""
|
||||
Clean up HistogramLUTItem safely, including open ViewBox menus and child widgets.
|
||||
Handle the removal of a layer by disconnecting the monitor.
|
||||
|
||||
Args:
|
||||
histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up.
|
||||
layer_name(str): The name of the layer that was removed.
|
||||
"""
|
||||
histogram_lut_item.vb.menu.close()
|
||||
histogram_lut_item.vb.menu.deleteLater()
|
||||
|
||||
histogram_lut_item.gradient.menu.close()
|
||||
histogram_lut_item.gradient.menu.deleteLater()
|
||||
histogram_lut_item.gradient.colorDialog.close()
|
||||
histogram_lut_item.gradient.colorDialog.deleteLater()
|
||||
if layer_name not in self.subscriptions:
|
||||
return
|
||||
config = self.subscriptions[layer_name]
|
||||
if config.monitor is not None:
|
||||
self.disconnect_monitor(config.monitor)
|
||||
config.monitor = None
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
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)
|
||||
self._main_image.config.monitor = None
|
||||
self.plot_item.removeItem(self._main_image)
|
||||
self._main_image = None
|
||||
|
||||
# Colorbar Cleanup
|
||||
if self._color_bar:
|
||||
if self.config.color_bar == "full":
|
||||
self.cleanup_histogram_lut_item(self._color_bar)
|
||||
if self.config.color_bar == "simple":
|
||||
self.plot_widget.removeItem(self._color_bar)
|
||||
self._color_bar.deleteLater()
|
||||
self._color_bar = None
|
||||
self.layer_removed.disconnect(self._on_layer_removed)
|
||||
for layer_name in list(self.subscriptions.keys()):
|
||||
config = self.subscriptions[layer_name]
|
||||
if config.monitor is not None:
|
||||
self.disconnect_monitor(config.monitor)
|
||||
del self.subscriptions[layer_name]
|
||||
self.subscriptions.clear()
|
||||
|
||||
# Toolbar cleanup
|
||||
self.toolbar.widgets["monitor"].widget.close()
|
||||
self.toolbar.widgets["monitor"].widget.deleteLater()
|
||||
|
||||
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_())
|
||||
|
||||
1062
bec_widgets/widgets/plots/image/image_base.py
Normal file
1062
bec_widgets/widgets/plots/image/image_base.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,9 +21,6 @@ logger = bec_logger.logger
|
||||
# noinspection PyDataclass
|
||||
class ImageItemConfig(ConnectionConfig): # TODO review config
|
||||
parent_id: str | None = Field(None, description="The parent plot of the image.")
|
||||
monitor: str | None = Field(None, description="The name of the monitor.")
|
||||
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
|
||||
source: str | None = Field(None, description="The source of the curve.")
|
||||
color_map: str | None = Field("plasma", description="The color map of the image.")
|
||||
downsample: bool | None = Field(True, description="Whether to downsample the image.")
|
||||
opacity: float | None = Field(1.0, description="The opacity of the image.")
|
||||
@@ -43,6 +40,7 @@ class ImageItemConfig(ConnectionConfig): # TODO review config
|
||||
|
||||
|
||||
class ImageItem(BECConnector, pg.ImageItem):
|
||||
|
||||
RPC = True
|
||||
USER_ACCESS = [
|
||||
"color_map",
|
||||
@@ -69,12 +67,13 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
]
|
||||
|
||||
vRangeChangedManually = Signal(tuple)
|
||||
removed = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[ImageItemConfig] = None,
|
||||
gui_id: Optional[str] = None,
|
||||
parent_image=None,
|
||||
parent_image=None, # FIXME: rename to parent
|
||||
**kwargs,
|
||||
):
|
||||
if config is None:
|
||||
@@ -274,6 +273,8 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
self.buffer = []
|
||||
self.max_len = 0
|
||||
|
||||
def remove(self):
|
||||
self.parent().disconnect_monitor(self.config.monitor)
|
||||
def remove(self, emit: bool = True):
|
||||
self.clear()
|
||||
super().remove()
|
||||
if emit:
|
||||
self.removed.emit(self.objectName())
|
||||
|
||||
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)
|
||||
|
||||
@@ -91,6 +91,8 @@ class MultiWaveform(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
"highlighted_index.setter",
|
||||
|
||||
@@ -112,8 +112,11 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
|
||||
self._user_x_label = ""
|
||||
self._x_label_suffix = ""
|
||||
self._x_axis_units = ""
|
||||
self._user_y_label = ""
|
||||
self._y_label_suffix = ""
|
||||
self._y_axis_units = ""
|
||||
self._minimal_crosshair_precision = 3
|
||||
|
||||
# Plot Indicator Items
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
@@ -473,12 +476,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 +543,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):
|
||||
"""
|
||||
@@ -938,7 +979,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Hook the crosshair to all plots."""
|
||||
if self.crosshair is None:
|
||||
self.crosshair = Crosshair(self.plot_item, precision=3)
|
||||
self.crosshair = Crosshair(
|
||||
self.plot_item, min_precision=self._minimal_crosshair_precision
|
||||
)
|
||||
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
|
||||
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
|
||||
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
|
||||
@@ -966,6 +1009,29 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
self.unhook_crosshair()
|
||||
|
||||
@SafeProperty(
|
||||
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
|
||||
)
|
||||
def minimal_crosshair_precision(self) -> int:
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
return self._minimal_crosshair_precision
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
def minimal_crosshair_precision(self, value: int):
|
||||
"""
|
||||
Set the minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
|
||||
Args:
|
||||
value(int): The minimum decimal places to set.
|
||||
"""
|
||||
value_int = max(0, int(value))
|
||||
self._minimal_crosshair_precision = value_int
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.min_precision = value_int
|
||||
self.property_changed.emit("minimal_crosshair_precision", value_int)
|
||||
|
||||
@SafeSlot()
|
||||
def reset(self) -> None:
|
||||
"""Reset the plot widget."""
|
||||
|
||||
@@ -113,6 +113,7 @@ class BaseROI(BECConnector):
|
||||
"line_width.setter",
|
||||
"get_coordinates",
|
||||
"get_data_from_image",
|
||||
"set_position",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -125,7 +126,7 @@ class BaseROI(BECConnector):
|
||||
# ROI-specific
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 10,
|
||||
line_width: int = 5,
|
||||
# all remaining pg.*ROI kwargs (pos, size, pen, …)
|
||||
**pg_kwargs,
|
||||
):
|
||||
@@ -149,7 +150,12 @@ class BaseROI(BECConnector):
|
||||
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, **pg_kwargs
|
||||
object_name=object_name,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
removable=True,
|
||||
invertible=True,
|
||||
**pg_kwargs,
|
||||
)
|
||||
|
||||
self._label = label or "ROI"
|
||||
@@ -333,7 +339,22 @@ class BaseROI(BECConnector):
|
||||
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:
|
||||
@@ -342,9 +363,8 @@ class BaseROI(BECConnector):
|
||||
continue
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.parent_image.plot_item.removeItem(self)
|
||||
if hasattr(self.parent_image, "roi_controller"):
|
||||
self.parent_image.roi_controller._rois.remove(self)
|
||||
self.parent_image.roi_controller._rebuild_color_buffer()
|
||||
viewBox = self.parent_plot_item.vb
|
||||
viewBox.update()
|
||||
|
||||
|
||||
class RectangularROI(BaseROI, pg.RectROI):
|
||||
@@ -378,7 +398,7 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
# ROI specifics
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 10,
|
||||
line_width: int = 5,
|
||||
resize_handles: bool = True,
|
||||
**extra_pg,
|
||||
):
|
||||
@@ -414,8 +434,6 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
|
||||
self.sigRegionChanged.connect(self._on_region_changed)
|
||||
self.adorner = LabelAdorner(roi=self)
|
||||
if resize_handles:
|
||||
self.add_scale_handle()
|
||||
self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine)
|
||||
self.handleHoverPen = fn.mkPen("lime", width=4)
|
||||
|
||||
@@ -440,6 +458,11 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
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.
|
||||
@@ -544,7 +567,7 @@ class CircularROI(BaseROI, pg.CircleROI):
|
||||
parent_image: Image | None = None,
|
||||
label: str | None = None,
|
||||
line_color: str | None = None,
|
||||
line_width: int = 10,
|
||||
line_width: int = 5,
|
||||
**extra_pg,
|
||||
):
|
||||
"""
|
||||
@@ -725,7 +748,7 @@ class ROIController(QObject):
|
||||
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 = 10
|
||||
roi.line_width = 5
|
||||
self.roiAdded.emit(roi)
|
||||
|
||||
def remove_roi(self, roi: BaseROI):
|
||||
@@ -738,8 +761,12 @@ class ROIController(QObject):
|
||||
Args:
|
||||
roi (BaseROI): The ROI instance to remove.
|
||||
"""
|
||||
rois = self._rois
|
||||
if roi not in rois:
|
||||
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:
|
||||
@@ -782,7 +809,7 @@ class ROIController(QObject):
|
||||
"""
|
||||
roi = self.get_roi(index)
|
||||
if roi is not None:
|
||||
roi.remove()
|
||||
self.remove_roi(roi)
|
||||
|
||||
def remove_roi_by_name(self, name: str):
|
||||
"""
|
||||
@@ -793,7 +820,7 @@ class ROIController(QObject):
|
||||
"""
|
||||
roi = self.get_roi_by_name(name)
|
||||
if roi is not None:
|
||||
roi.remove()
|
||||
self.remove_roi(roi)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
@@ -803,7 +830,7 @@ class ROIController(QObject):
|
||||
the cleared signal to notify listeners that all ROIs have been removed.
|
||||
"""
|
||||
for roi in list(self._rois):
|
||||
roi.remove()
|
||||
self.remove_roi(roi)
|
||||
self.cleared.emit()
|
||||
|
||||
def renormalize_colors(self):
|
||||
|
||||
@@ -82,6 +82,8 @@ class ScatterWaveform(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
"color_map",
|
||||
|
||||
@@ -60,6 +60,7 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_grid,
|
||||
self.ui.inner_axes,
|
||||
self.ui.outer_axes,
|
||||
self.ui.minimal_crosshair_precision,
|
||||
]:
|
||||
WidgetIO.connect_widget_change_signal(widget, self.set_property)
|
||||
|
||||
@@ -121,6 +122,7 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_max,
|
||||
self.ui.y_log,
|
||||
self.ui.y_grid,
|
||||
self.ui.minimal_crosshair_precision,
|
||||
]:
|
||||
property_name = widget.objectName()
|
||||
value = getattr(self.target_widget, property_name)
|
||||
@@ -144,6 +146,7 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_grid,
|
||||
self.ui.outer_axes,
|
||||
self.ui.inner_axes,
|
||||
self.ui.minimal_crosshair_precision,
|
||||
]:
|
||||
property_name = widget.objectName()
|
||||
value = WidgetIO.get_value(widget)
|
||||
|
||||
@@ -14,97 +14,6 @@
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="2">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="ToggleSwitch" name="x_log">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="ToggleSwitch" name="x_grid">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BECSpinBox" name="x_min"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="BECSpinBox" name="x_max"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2" colspan="2">
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
@@ -179,6 +88,87 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="2">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="ToggleSwitch" name="x_log">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="ToggleSwitch" name="x_grid">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BECSpinBox" name="x_min"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="BECSpinBox" name="x_max"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="4">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
@@ -191,8 +181,41 @@
|
||||
<item>
|
||||
<widget class="QLineEdit" name="title"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="minimal_crosshair_precision">
|
||||
<property name="toolTip">
|
||||
<string>Minimal Crosshair Precision</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>20</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
|
||||
@@ -6,15 +6,84 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>241</width>
|
||||
<height>526</height>
|
||||
<width>250</width>
|
||||
<height>612</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="4" column="0" colspan="2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="general_box">
|
||||
<property name="title">
|
||||
<string>General</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="title"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="plot_title_label">
|
||||
<property name="text">
|
||||
<string>Plot Title</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="toolTip">
|
||||
<string>Minimal Crosshair Precision</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="minimal_crosshair_precision">
|
||||
<property name="toolTip">
|
||||
<string>Minimal Crosshair Precision</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>20</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
@@ -81,28 +150,7 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="2">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="plot_title_label">
|
||||
<property name="text">
|
||||
<string>Plot Title</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="title"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0" colspan="2">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
<string>Y Axis</string>
|
||||
@@ -169,23 +217,6 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeSlot
|
||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
@@ -154,7 +155,7 @@ class CurveRow(QTreeWidgetItem):
|
||||
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
|
||||
# Color in col 3
|
||||
self.color_button = ColorButtonNative(color=self.config.color)
|
||||
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
|
||||
self.color_button.color_changed.connect(self._on_color_changed)
|
||||
self.tree.setItemWidget(self, 3, self.color_button)
|
||||
|
||||
# Style in col 4
|
||||
@@ -177,20 +178,16 @@ class CurveRow(QTreeWidgetItem):
|
||||
self.symbol_spin.setValue(self.config.symbol_size)
|
||||
self.tree.setItemWidget(self, 6, self.symbol_spin)
|
||||
|
||||
def _select_color(self, button):
|
||||
@SafeSlot(str, verify_sender=True)
|
||||
def _on_color_changed(self, new_color: str):
|
||||
"""
|
||||
Selects a new color using a color dialog and applies it to the specified button. Updates
|
||||
related configuration properties based on the chosen color.
|
||||
Update configuration when the color button emits a change.
|
||||
|
||||
Args:
|
||||
button: The button widget whose color is being modified.
|
||||
new_color (str): The new color in hex format.
|
||||
"""
|
||||
current_color = QColor(button.color())
|
||||
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
|
||||
if chosen_color.isValid():
|
||||
button.set_color(chosen_color)
|
||||
self.config.color = chosen_color.name()
|
||||
self.config.symbol_color = chosen_color.name()
|
||||
self.config.color = new_color
|
||||
self.config.symbol_color = new_color
|
||||
|
||||
def add_dap_row(self):
|
||||
"""Create a new DAP row as a child. Only valid if source='device'."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import lmfit
|
||||
import numpy as np
|
||||
@@ -9,8 +9,19 @@ import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
@@ -33,6 +44,11 @@ class WaveformConfig(ConnectionConfig):
|
||||
color_palette: str | None = Field(
|
||||
"plasma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
max_dataset_size_mb: float = Field(
|
||||
10,
|
||||
description="Maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.",
|
||||
validate_default=True,
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_palette = field_validator("color_palette")(Colors.validate_color_map)
|
||||
@@ -86,6 +102,8 @@ class Waveform(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
@@ -94,6 +112,12 @@ class Waveform(PlotBase):
|
||||
"x_entry.setter",
|
||||
"color_palette",
|
||||
"color_palette.setter",
|
||||
"skip_large_dataset_warning",
|
||||
"skip_large_dataset_warning.setter",
|
||||
"skip_large_dataset_check",
|
||||
"skip_large_dataset_check.setter",
|
||||
"max_dataset_size_mb",
|
||||
"max_dataset_size_mb.setter",
|
||||
"plot",
|
||||
"add_dap_curve",
|
||||
"remove_curve",
|
||||
@@ -139,9 +163,10 @@ class Waveform(PlotBase):
|
||||
self._async_curves = []
|
||||
self._slice_index = None
|
||||
self._dap_curves = []
|
||||
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
|
||||
self._mode = None
|
||||
|
||||
# Scan data
|
||||
self._scan_done = True # means scan is not running
|
||||
self.old_scan_id = None
|
||||
self.scan_id = None
|
||||
self.scan_item = None
|
||||
@@ -161,6 +186,10 @@ class Waveform(PlotBase):
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
|
||||
# Large‑dataset guard
|
||||
self._skip_large_dataset_warning = False # session flag
|
||||
self._skip_large_dataset_check = False # per-plot flag, to skip the warning for this plot
|
||||
|
||||
# Scan status update loop
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
@@ -414,6 +443,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)
|
||||
@@ -557,6 +588,59 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
return [item for item in self.plot_item.curves if isinstance(item, Curve)]
|
||||
|
||||
@SafeProperty(bool)
|
||||
def skip_large_dataset_check(self) -> bool:
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
return self._skip_large_dataset_check
|
||||
|
||||
@skip_large_dataset_check.setter
|
||||
def skip_large_dataset_check(self, value: bool):
|
||||
"""
|
||||
Set whether to skip the large dataset warning when fetching async data.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to skip the large dataset warning.
|
||||
"""
|
||||
self._skip_large_dataset_check = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def skip_large_dataset_warning(self) -> bool:
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
return self._skip_large_dataset_warning
|
||||
|
||||
@skip_large_dataset_warning.setter
|
||||
def skip_large_dataset_warning(self, value: bool):
|
||||
"""
|
||||
Set whether to skip the large dataset warning when fetching async data.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to skip the large dataset warning.
|
||||
"""
|
||||
self._skip_large_dataset_warning = value
|
||||
|
||||
@SafeProperty(float)
|
||||
def max_dataset_size_mb(self) -> float:
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
return self.config.max_dataset_size_mb
|
||||
|
||||
@max_dataset_size_mb.setter
|
||||
def max_dataset_size_mb(self, value: float):
|
||||
"""
|
||||
Set the maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
|
||||
Args:
|
||||
value(float): The maximum dataset size in MB.
|
||||
"""
|
||||
if value <= 0:
|
||||
raise ValueError("Maximum dataset size must be greater than 0.")
|
||||
self.config.max_dataset_size_mb = value
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
@@ -803,8 +887,6 @@ class Waveform(PlotBase):
|
||||
if config.source == "device":
|
||||
if self.scan_item is None:
|
||||
self.update_with_scan_history(-1)
|
||||
if curve in self._async_curves:
|
||||
self._setup_async_curve(curve)
|
||||
self.async_signal_update.emit()
|
||||
self.sync_signal_update.emit()
|
||||
if config.source == "dap":
|
||||
@@ -1052,12 +1134,12 @@ class Waveform(PlotBase):
|
||||
meta(dict): The message metadata.
|
||||
"""
|
||||
self.sync_signal_update.emit()
|
||||
status = msg.get("done")
|
||||
if status:
|
||||
self._scan_done = msg.get("done")
|
||||
if self._scan_done:
|
||||
QTimer.singleShot(100, self.update_sync_curves)
|
||||
QTimer.singleShot(300, self.update_sync_curves)
|
||||
|
||||
def _fetch_scan_data_and_access(self):
|
||||
def _fetch_scan_data_and_access(self) -> tuple[dict, str] | tuple[None, None]:
|
||||
"""
|
||||
Decide whether the widget is in live or historical mode
|
||||
and return the appropriate data dict and access key.
|
||||
@@ -1071,7 +1153,7 @@ class Waveform(PlotBase):
|
||||
self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none", "none"
|
||||
return None, None
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
# Live scan
|
||||
@@ -1087,7 +1169,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none"
|
||||
return
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
for curve in self._sync_curves:
|
||||
device_name = curve.config.signal.name
|
||||
@@ -1095,9 +1177,8 @@ class Waveform(PlotBase):
|
||||
if access_key == "val":
|
||||
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
|
||||
else:
|
||||
device_data = (
|
||||
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
|
||||
)
|
||||
entry_obj = data.get(device_name, {}).get(device_entry)
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
x_data = self._get_x_data(device_name, device_entry)
|
||||
if x_data is not None:
|
||||
if len(x_data) == 1:
|
||||
@@ -1131,9 +1212,12 @@ class Waveform(PlotBase):
|
||||
if access_key == "val": # live access
|
||||
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
|
||||
else: # history access
|
||||
device_data = (
|
||||
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
|
||||
)
|
||||
dataset_obj = data.get(device_name, {})
|
||||
if self._skip_large_dataset_check is False:
|
||||
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
|
||||
continue # user declined to load; skip this curve
|
||||
entry_obj = dataset_obj.get(device_entry, None)
|
||||
device_data = entry_obj.read()["value"] if entry_obj else None
|
||||
|
||||
# if shape is 2D cast it into 1D and take the last waveform
|
||||
if len(np.shape(device_data)) > 1:
|
||||
@@ -1465,29 +1549,35 @@ class Waveform(PlotBase):
|
||||
if access_key == "val": # live data
|
||||
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}]"
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else [0]
|
||||
new_suffix = f" (custom: {x_name}-{x_entry})"
|
||||
|
||||
# 2 User wants timestamp
|
||||
if self.x_axis_mode["name"] == "timestamp":
|
||||
if access_key == "val": # live
|
||||
timestamps = data[device_name][device_entry].timestamps
|
||||
x_data = data.get(device_name, {}).get(device_entry, None)
|
||||
if x_data is None:
|
||||
return None
|
||||
else:
|
||||
timestamps = x_data.timestamps
|
||||
else: # history data
|
||||
timestamps = data[device_name][device_entry].read().get("timestamp", [0])
|
||||
entry_obj = data.get(device_name, {}).get(device_entry)
|
||||
timestamps = entry_obj.read()["timestamp"] if entry_obj else [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:
|
||||
@@ -1500,8 +1590,9 @@ class Waveform(PlotBase):
|
||||
if access_key == "val":
|
||||
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}]"
|
||||
entry_obj = data.get(x_name, {}).get(x_entry)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||
new_suffix = f" (auto: {x_name}-{x_entry})"
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
return x_data
|
||||
|
||||
@@ -1553,7 +1644,7 @@ class Waveform(PlotBase):
|
||||
self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
return "none"
|
||||
return None
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
|
||||
@@ -1577,6 +1668,8 @@ class Waveform(PlotBase):
|
||||
dev_name = curve.config.signal.name
|
||||
if dev_name in readout_priority_async:
|
||||
self._async_curves.append(curve)
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
self._setup_async_curve(curve)
|
||||
found_async = True
|
||||
elif dev_name in readout_priority_sync:
|
||||
self._sync_curves.append(curve)
|
||||
@@ -1653,6 +1746,106 @@ class Waveform(PlotBase):
|
||||
################################################################################
|
||||
# Utility Methods
|
||||
################################################################################
|
||||
|
||||
# Large dataset handling helpers
|
||||
def _check_dataset_size_and_confirm(self, dataset_obj, device_entry: str) -> bool:
|
||||
"""
|
||||
Check the size of the dataset and confirm with the user if it exceeds the limit.
|
||||
|
||||
Args:
|
||||
dataset_obj: The dataset object containing the information.
|
||||
device_entry( str): The specific device entry to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the dataset is within the size limit or user confirmed to load it,
|
||||
False if the dataset exceeds the size limit and user declined to load it.
|
||||
"""
|
||||
try:
|
||||
info = dataset_obj._info
|
||||
mem_bytes = info.get(device_entry, {}).get("value", {}).get("mem_size", 0)
|
||||
# Fallback – grab first entry if lookup failed
|
||||
if mem_bytes == 0 and info:
|
||||
first_key = next(iter(info))
|
||||
mem_bytes = info[first_key]["value"]["mem_size"]
|
||||
size_mb = mem_bytes / (1024 * 1024)
|
||||
print(f"Dataset size: {size_mb:.1f} MB")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Unable to evaluate dataset size: {exc}")
|
||||
return True
|
||||
|
||||
if size_mb <= self.config.max_dataset_size_mb:
|
||||
return True
|
||||
logger.warning(
|
||||
f"Attempt to load large dataset: {size_mb:.1f} MB "
|
||||
f"(limit {self.config.max_dataset_size_mb} MB)"
|
||||
)
|
||||
if self._skip_large_dataset_warning:
|
||||
logger.info("Skipping large dataset warning dialog.")
|
||||
return False
|
||||
return self._confirm_large_dataset(size_mb)
|
||||
|
||||
def _confirm_large_dataset(self, size_mb: float) -> bool:
|
||||
"""
|
||||
Confirm with the user whether to load a large dataset with dialog popup.
|
||||
Also allows the user to adjust the maximum dataset size limit and if user
|
||||
wants to see this popup again during session.
|
||||
|
||||
Args:
|
||||
size_mb(float): Size of the dataset in MB.
|
||||
|
||||
Returns:
|
||||
bool: True if the user confirmed to load the dataset, False otherwise.
|
||||
"""
|
||||
if self._skip_large_dataset_warning:
|
||||
return True
|
||||
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Large dataset detected")
|
||||
main_dialog_layout = QVBoxLayout(dialog)
|
||||
|
||||
# Limit adjustment widgets
|
||||
limit_adjustment_layout = QHBoxLayout()
|
||||
limit_adjustment_layout.addWidget(QLabel("New limit (MB):"))
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0.001, 4096)
|
||||
spin.setDecimals(3)
|
||||
spin.setSingleStep(0.01)
|
||||
spin.setValue(self.config.max_dataset_size_mb)
|
||||
spin.valueChanged.connect(lambda value: setattr(self.config, "max_dataset_size_mb", value))
|
||||
limit_adjustment_layout.addWidget(spin)
|
||||
|
||||
# Don't show again checkbox
|
||||
checkbox = QCheckBox("Don't show this again for this session")
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.Yes | QDialogButtonBox.No, Qt.Horizontal, dialog
|
||||
)
|
||||
buttons.accepted.connect(dialog.accept) # Yes
|
||||
buttons.rejected.connect(dialog.reject) # No
|
||||
|
||||
# widget layout
|
||||
main_dialog_layout.addWidget(
|
||||
QLabel(
|
||||
f"The selected dataset is {size_mb:.1f} MB which exceeds the "
|
||||
f"current limit of {self.config.max_dataset_size_mb} MB.\n"
|
||||
)
|
||||
)
|
||||
main_dialog_layout.addLayout(limit_adjustment_layout)
|
||||
main_dialog_layout.addWidget(checkbox)
|
||||
main_dialog_layout.addWidget(QLabel("Would you like to display dataset anyway?"))
|
||||
main_dialog_layout.addWidget(buttons)
|
||||
|
||||
result = dialog.exec() # modal; waits for user choice
|
||||
|
||||
# Respect the “don't show again” checkbox for *either* choice
|
||||
if checkbox.isChecked():
|
||||
self._skip_large_dataset_warning = True
|
||||
|
||||
if result == QDialog.Accepted:
|
||||
self.config.max_dataset_size_mb = spin.value()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ensure_str_list(self, entries: list | tuple | np.ndarray):
|
||||
"""
|
||||
Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either)
|
||||
@@ -1783,7 +1976,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.waveform_popup = Waveform(popups=True)
|
||||
self.waveform_popup.plot(y_name="monitor_async")
|
||||
self.waveform_popup.plot(y_name="waveform")
|
||||
|
||||
self.waveform_side = Waveform(popups=False)
|
||||
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
from functools import partial
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import Signal, Slot
|
||||
from qtpy.QtWidgets import QListWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceBrowser(BECWidget, QWidget):
|
||||
@@ -23,18 +30,18 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent: QWidget | None = None,
|
||||
config=None,
|
||||
client=None,
|
||||
gui_id: Optional[str] = None,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.ui = None
|
||||
self.ini_ui()
|
||||
|
||||
self.dev_list: QListWidget = self.ui.device_list
|
||||
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
|
||||
self.proxy_device_update = SignalProxy(
|
||||
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
|
||||
)
|
||||
@@ -43,6 +50,7 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
)
|
||||
self.device_update.connect(self.update_device_list)
|
||||
|
||||
self.init_device_list()
|
||||
self.update_device_list()
|
||||
|
||||
def ini_ui(self) -> None:
|
||||
@@ -50,14 +58,12 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
Initialize the UI by loading the UI file and setting the layout.
|
||||
"""
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "device_browser.ui")
|
||||
self.ui = UILoader(self).loader(ui_file_path)
|
||||
layout.addWidget(self.ui)
|
||||
self.setLayout(layout)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
def on_device_update(self, action: ConfigAction, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
|
||||
@@ -68,8 +74,43 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self.device_update.emit()
|
||||
|
||||
@Slot()
|
||||
def update_device_list(self) -> None:
|
||||
def init_device_list(self):
|
||||
self.dev_list.clear()
|
||||
self._device_items: dict[str, QListWidgetItem] = {}
|
||||
|
||||
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
|
||||
device_item.adjustSize()
|
||||
item.setSizeHint(QSize(device_item.width(), device_item.height()))
|
||||
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
|
||||
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for device, device_obj in self.dev.items():
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
parent=self, device=device, icon=map_device_type_to_icon(device_obj)
|
||||
)
|
||||
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
|
||||
device_config = self.dev[device]._config # pylint: disable=protected-access
|
||||
device_item.set_display_config(device_config)
|
||||
tooltip = device_config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
|
||||
self.dev_list.setItemWidget(item, device_item)
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
|
||||
@SafeSlot()
|
||||
def reset_device_list(self) -> None:
|
||||
self.init_device_list()
|
||||
self.update_device_list()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
def update_device_list(self, *_) -> None:
|
||||
"""
|
||||
Update the device list based on the filter input.
|
||||
There are two ways to trigger this function:
|
||||
@@ -80,23 +121,14 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
"""
|
||||
filter_text = self.ui.filter_input.text()
|
||||
try:
|
||||
regex = re.compile(filter_text, re.IGNORECASE)
|
||||
self.regex = re.compile(filter_text, re.IGNORECASE)
|
||||
except re.error:
|
||||
regex = None # Invalid regex, disable filtering
|
||||
|
||||
dev_list = self.ui.device_list
|
||||
dev_list.clear()
|
||||
self.regex = None # Invalid regex, disable filtering
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(False)
|
||||
return
|
||||
for device in self.dev:
|
||||
if regex is None or regex.search(device):
|
||||
item = QListWidgetItem(dev_list)
|
||||
device_item = DeviceItem(device)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
dev_list.setItemWidget(item, device_item)
|
||||
dev_list.addItem(item)
|
||||
self._device_items[device].setHidden(not self.regex.search(device))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
@@ -104,10 +136,10 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
set_theme("light")
|
||||
widget = DeviceBrowser()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -2,10 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceConfigModel
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QMimeData, Qt
|
||||
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
@@ -13,26 +21,77 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceItem(QWidget):
|
||||
def __init__(self, device: str) -> None:
|
||||
super().__init__()
|
||||
class DeviceItemForm(PydanticModelForm):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
data_model=DeviceConfigModel,
|
||||
pretty_display=pretty_display,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
self._validity.setVisible(False)
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str | None = None):
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent, device: str, icon: str = "") -> None:
|
||||
super().__init__(parent, title=device, expanded=False, icon=icon)
|
||||
|
||||
self._drag_pos = None
|
||||
|
||||
self._expanded_first_time = False
|
||||
self._data = None
|
||||
self.device = device
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(10, 2, 10, 2)
|
||||
self.label = QLabel(device)
|
||||
layout.addWidget(self.label)
|
||||
self.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.set_layout(layout)
|
||||
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
"""
|
||||
)
|
||||
self.adjustSize()
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
if not self.expanded and not self._expanded_first_time:
|
||||
self._expanded_first_time = True
|
||||
self.form = DeviceItemForm(parent=self, pretty_display=True)
|
||||
self._contents.layout().addWidget(self.form)
|
||||
if self._data:
|
||||
self.form.set_data(self._data)
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
super().switch_expanded_state()
|
||||
if self._expanded_first_time:
|
||||
self.form.adjustSize()
|
||||
self.updateGeometry()
|
||||
if self._expanded:
|
||||
self.form.set_pretty_display_theme()
|
||||
self.adjustSize()
|
||||
self.broadcast_size_hint.emit(self.sizeHint())
|
||||
|
||||
def set_display_config(self, config_dict: dict):
|
||||
"""Set the displayed information from a device config dict, which must conform to the
|
||||
bec_lib.atlas_models.Device config model."""
|
||||
self._data = DeviceConfigModel.model_validate(config_dict)
|
||||
if self._expanded_first_time:
|
||||
self.form.set_data(self._data)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
super().mousePressEvent(event)
|
||||
@@ -63,6 +122,25 @@ if __name__ == "__main__": # pragma: no cover
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = DeviceItem("Device")
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout()
|
||||
widget.setLayout(layout)
|
||||
item = DeviceItem("Device")
|
||||
layout.addWidget(DarkModeButton())
|
||||
layout.addWidget(item)
|
||||
item.set_display_config(
|
||||
{
|
||||
"name": "Test Device",
|
||||
"enabled": True,
|
||||
"deviceClass": "FakeDeviceClass",
|
||||
"deviceConfig": {"kwarg1": "value1"},
|
||||
"readoutPriority": "baseline",
|
||||
"description": "A device for testing out a widget",
|
||||
"readOnly": True,
|
||||
"softwareTrigger": False,
|
||||
"deviceTags": ["tag1", "tag2", "tag3"],
|
||||
"userParameter": {"some_setting": "some_ value"},
|
||||
}
|
||||
)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
11
bec_widgets/widgets/services/device_browser/util.py
Normal file
11
bec_widgets/widgets/services/device_browser/util.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from bec_lib.device import Device
|
||||
|
||||
|
||||
def map_device_type_to_icon(device_obj: Device) -> str:
|
||||
"""Associate device types with material icon names"""
|
||||
match device_obj._info.get("device_base_class", "").lower():
|
||||
case "positioner":
|
||||
return "precision_manufacturing"
|
||||
case "signal":
|
||||
return "vital_signs"
|
||||
return "deployed_code"
|
||||
@@ -11,12 +11,11 @@ from re import Pattern
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.connector import ConnectorBase
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import LogLevel, bec_logger
|
||||
from bec_lib.messages import LogMessage, StatusMessage
|
||||
from PySide6.QtCore import QObject
|
||||
from qtpy.QtCore import QDateTime, Qt, Signal
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
||||
from qtpy.QtGui import QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -35,6 +34,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 +69,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,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._timestamp_start: QDateTime | None = None
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
self._conn = conn
|
||||
self._max_length = maxlen
|
||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
||||
self._display_queue: deque[str] = deque([], self._max_length)
|
||||
@@ -92,20 +92,26 @@ 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.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
||||
|
||||
def unsub_from_redis(self):
|
||||
def cleanup(self, *_):
|
||||
"""Stop listening to the Redis log stream"""
|
||||
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||
)
|
||||
|
||||
def _process_incoming_log_msg(self, msg: dict):
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||
try:
|
||||
_msg: LogMessage = msg["data"]
|
||||
_msg = LogMessage(**msg)
|
||||
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:
|
||||
return
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
@@ -202,7 +208,7 @@ class BecLogsQueue(QObject):
|
||||
"""Fetch all available messages from Redis"""
|
||||
self._data = deque(
|
||||
item["data"]
|
||||
for item in self._conn.xread(
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
||||
)
|
||||
)
|
||||
@@ -396,7 +402,6 @@ class LogPanel(TextBox):
|
||||
"""Displays a log panel"""
|
||||
|
||||
ICON_NAME = "terminal"
|
||||
_new_messages = Signal()
|
||||
service_list_update = Signal(dict, set)
|
||||
|
||||
def __init__(
|
||||
@@ -407,17 +412,17 @@ 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._proxy_update = SignalProxy(
|
||||
self._log_manager.new_message, rateLimit=1, slot=self._on_append
|
||||
)
|
||||
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)
|
||||
@@ -431,7 +436,6 @@ class LogPanel(TextBox):
|
||||
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
|
||||
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
|
||||
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
|
||||
self._new_messages.connect(self._on_append)
|
||||
|
||||
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
|
||||
self._service_status.services_update.connect(self._update_service_list)
|
||||
@@ -483,10 +487,10 @@ class LogPanel(TextBox):
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_append(self):
|
||||
self._cursor_to_end()
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _on_append(self, *_):
|
||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_clear(self):
|
||||
@@ -529,9 +533,8 @@ class LogPanel(TextBox):
|
||||
|
||||
def cleanup(self):
|
||||
self._service_status.cleanup()
|
||||
self._log_manager.unsub_from_redis()
|
||||
self._log_manager.new_message.disconnect(self._new_messages)
|
||||
self._new_messages.disconnect(self._on_append)
|
||||
self._log_manager.cleanup()
|
||||
self._log_manager.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.console.console_plugin import BECConsolePlugin
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label_plugin import SignalLabelPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLabelPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
456
bec_widgets/widgets/utility/signal_label/signal_label.py
Normal file
456
bec_widgets/widgets/utility/signal_label/signal_label.py
Normal file
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.device import Device, Signal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal as QSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
DeviceInputConfig,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
|
||||
class ChoiceDialog(QDialog):
|
||||
accepted_output = QSignal(str, str)
|
||||
|
||||
CONNECTION_ERROR_STR = "Error: client is not connected!"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ConnectionConfig | None = None,
|
||||
client: BECClient | None = None,
|
||||
show_hinted: bool = True,
|
||||
show_normal: bool = False,
|
||||
show_config: bool = False,
|
||||
):
|
||||
if not client or not client.started:
|
||||
self._display_error()
|
||||
return
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Choose device and signal...")
|
||||
self._accent_colors = get_accent_colors()
|
||||
|
||||
layout = QHBoxLayout()
|
||||
|
||||
config_dict = config.model_dump() if config is not None else {}
|
||||
self._device_config = DeviceInputConfig.model_validate(config_dict)
|
||||
self._signal_config = DeviceSignalInputBaseConfig.model_validate(config_dict)
|
||||
self._device_field = DeviceLineEdit(
|
||||
config=self._device_config, parent=parent, client=client
|
||||
)
|
||||
self._signal_field = SignalComboBox(
|
||||
config=self._signal_config,
|
||||
device=self._signal_config.device,
|
||||
parent=parent,
|
||||
client=client,
|
||||
)
|
||||
layout.addWidget(self._device_field)
|
||||
layout.addWidget(self._signal_field)
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
|
||||
self._signal_field.include_hinted_signals = show_hinted
|
||||
self._signal_field.include_normal_signals = show_normal
|
||||
self._signal_field.include_config_signals = show_config
|
||||
|
||||
self.setLayout(layout)
|
||||
self._device_field.textChanged.connect(self._update_device)
|
||||
self._device_field.setText(config.device if config is not None else "")
|
||||
|
||||
def _display_error(self):
|
||||
try:
|
||||
super().__init__()
|
||||
except Exception:
|
||||
...
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(QLabel(self.CONNECTION_ERROR_STR))
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
self.setLayout(layout)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _update_device(self, device: str):
|
||||
if device in self._device_field.dev:
|
||||
self._device_field.set_device(device)
|
||||
self._signal_field.set_device(device)
|
||||
self._device_field.setStyleSheet(
|
||||
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}"
|
||||
)
|
||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
|
||||
else:
|
||||
self._device_field.setStyleSheet(
|
||||
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}"
|
||||
)
|
||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self._signal_field.clear()
|
||||
|
||||
def accept(self):
|
||||
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
|
||||
return super().accept()
|
||||
|
||||
|
||||
class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
ICON_NAME = "scoreboard"
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
|
||||
USER_ACCESS = [
|
||||
"custom_label",
|
||||
"custom_units",
|
||||
"custom_label.setter",
|
||||
"custom_units.setter",
|
||||
"decimal_places",
|
||||
"decimal_places.setter",
|
||||
"show_default_units",
|
||||
"show_default_units.setter",
|
||||
"show_select_button",
|
||||
"show_select_button.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
client: BECClient | None = None,
|
||||
device: str | None = None,
|
||||
signal: str | None = None,
|
||||
show_select_button: bool = True,
|
||||
show_default_units: bool = False,
|
||||
custom_label: str = "",
|
||||
custom_units: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the SignalLabel widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
client (BECClient, optional): The BEC client. Defaults to None.
|
||||
device (str, optional): The device name. Defaults to None.
|
||||
signal (str, optional): The signal name. Defaults to None.
|
||||
selection_dialog_config (DeviceSignalInputBaseConfig | dict, optional): Configuration for the signal selection dialog.
|
||||
show_select_button (bool, optional): Whether to show the select button. Defaults to True.
|
||||
show_default_units (bool, optional): Whether to show default units. Defaults to False.
|
||||
custom_label (str, optional): Custom label for the widget. Defaults to "".
|
||||
custom_units (str, optional): Custom units for the widget. Defaults to "".
|
||||
"""
|
||||
self._config = DeviceSignalInputBaseConfig(default=signal, device=device)
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
self._device = device
|
||||
self._signal = signal
|
||||
|
||||
self._custom_label: str = custom_label
|
||||
self._custom_units: str = custom_units
|
||||
self._show_default_units: bool = show_default_units
|
||||
self._decimal_places = 3
|
||||
|
||||
self._show_hinted_signals: bool = True
|
||||
self._show_normal_signals: bool = False
|
||||
self._show_config_signals: bool = False
|
||||
|
||||
self._outer_layout = QHBoxLayout()
|
||||
self._layout = QHBoxLayout()
|
||||
self._outer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._outer_layout)
|
||||
|
||||
self._label = QGroupBox(custom_label)
|
||||
self._outer_layout.addWidget(self._label)
|
||||
self._update_label()
|
||||
self._label.setLayout(self._layout)
|
||||
|
||||
self._value: str = ""
|
||||
self._display = QLabel()
|
||||
self._layout.addWidget(self._display)
|
||||
|
||||
self._select_button = QToolButton()
|
||||
self._select_button.setIcon(material_icon(icon_name="settings", size=(20, 20)))
|
||||
self._show_select_button: bool = show_select_button
|
||||
self._layout.addWidget(self._select_button)
|
||||
self._display.setMinimumHeight(self._select_button.sizeHint().height())
|
||||
self.show_select_button = self._show_select_button
|
||||
|
||||
self._select_button.clicked.connect(self.show_choice_dialog)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self._connected: bool = False
|
||||
self.connect_device()
|
||||
|
||||
def _create_dialog(self):
|
||||
return ChoiceDialog(
|
||||
config=self._config,
|
||||
parent=self,
|
||||
client=self.client,
|
||||
show_config=self.show_config_signals,
|
||||
show_normal=self.show_normal_signals,
|
||||
show_hinted=self.show_hinted_signals,
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _process_dialog(self, device: str, signal: str):
|
||||
self.disconnect_device()
|
||||
self.device = device
|
||||
self.signal = signal
|
||||
self._update_label()
|
||||
self.connect_device()
|
||||
|
||||
def show_choice_dialog(self):
|
||||
dialog = self._create_dialog()
|
||||
dialog.accepted_output.connect(self._process_dialog)
|
||||
dialog.open()
|
||||
return dialog
|
||||
|
||||
def connect_device(self):
|
||||
"""Subscribe to the Redis topic for the device to display"""
|
||||
if not self._connected and self._device and self._device in self.dev:
|
||||
self._connected = True
|
||||
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
self._manual_read()
|
||||
self.set_display_value(self._value)
|
||||
|
||||
def disconnect_device(self):
|
||||
"""Unsubscribe from the Redis topic for the device to display"""
|
||||
if self._connected:
|
||||
self._connected = False
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
|
||||
def _manual_read(self):
|
||||
if self._device is None or not isinstance(
|
||||
(device := self.dev.get(self._device)), Device | Signal
|
||||
):
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
return
|
||||
signal: Signal = (
|
||||
getattr(device, self.signal, None) if isinstance(device, Device) else device
|
||||
)
|
||||
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
|
||||
signal = None
|
||||
if signal is None:
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
return
|
||||
self._value = signal.get()
|
||||
self._units = signal.get_device_config().get("egu", "")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update the display with the new value.
|
||||
"""
|
||||
try:
|
||||
signal_to_read = self._patch_hinted_signal()
|
||||
self._value = msg["signals"][signal_to_read]["value"]
|
||||
self.set_display_value(self._value)
|
||||
except Exception as e:
|
||||
self._display.setText("ERROR!")
|
||||
self._display.setToolTip(
|
||||
f"Error processing incoming reading: {msg}, handled with exception: {''.join(traceback.format_exception(e))}"
|
||||
)
|
||||
|
||||
def _patch_hinted_signal(self):
|
||||
if self.dev[self._device]._info["signals"] == {}:
|
||||
return self._signal
|
||||
signal_info = self.dev[self._device]._info["signals"][self._signal]
|
||||
return (
|
||||
signal_info["obj_name"] if signal_info["kind_str"] == Kind.hinted.name else self._signal
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self) -> str:
|
||||
"""The device from which to select a signal"""
|
||||
return self._device or "Not set!"
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str) -> None:
|
||||
self.disconnect_device()
|
||||
self._device = value
|
||||
self._config.device = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal(self) -> str:
|
||||
"""The signal to display"""
|
||||
return self._signal or "Not set!"
|
||||
|
||||
@signal.setter
|
||||
def signal(self, value: str) -> None:
|
||||
self.disconnect_device()
|
||||
self._signal = value
|
||||
self._config.default = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_select_button(self) -> bool:
|
||||
"""Show the button to select the signal to display"""
|
||||
return self._show_select_button
|
||||
|
||||
@show_select_button.setter
|
||||
def show_select_button(self, value: bool) -> None:
|
||||
self._show_select_button = value
|
||||
self._select_button.setVisible(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_default_units(self) -> bool:
|
||||
"""Show default units obtained from the signal alongside it"""
|
||||
return self._show_default_units
|
||||
|
||||
@show_default_units.setter
|
||||
def show_default_units(self, value: bool) -> None:
|
||||
self._show_default_units = value
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@SafeProperty(str)
|
||||
def custom_label(self) -> str:
|
||||
"""Use a cusom label rather than the signal name"""
|
||||
return self._custom_label
|
||||
|
||||
@custom_label.setter
|
||||
def custom_label(self, value: str) -> None:
|
||||
self._custom_label = value
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(str)
|
||||
def custom_units(self) -> str:
|
||||
"""Use a custom unit string"""
|
||||
return self._custom_units
|
||||
|
||||
@custom_units.setter
|
||||
def custom_units(self, value: str) -> None:
|
||||
self._custom_units = value
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@SafeProperty(int)
|
||||
def decimal_places(self) -> int:
|
||||
"""Format to a given number of decimal_places. Set to 0 to disable."""
|
||||
return self._decimal_places
|
||||
|
||||
@decimal_places.setter
|
||||
def decimal_places(self, value: int) -> None:
|
||||
self._decimal_places = value
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_hinted_signals(self) -> bool:
|
||||
"""In the signal selection menu, show hinted signals"""
|
||||
return self._show_hinted_signals
|
||||
|
||||
@show_hinted_signals.setter
|
||||
def show_hinted_signals(self, value: bool) -> None:
|
||||
self._show_hinted_signals = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_config_signals(self) -> bool:
|
||||
"""In the signal selection menu, show config signals"""
|
||||
return self._show_config_signals
|
||||
|
||||
@show_config_signals.setter
|
||||
def show_config_signals(self, value: bool) -> None:
|
||||
self._show_config_signals = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_normal_signals(self) -> bool:
|
||||
"""In the signal selection menu, show normal signals"""
|
||||
return self._show_normal_signals
|
||||
|
||||
@show_normal_signals.setter
|
||||
def show_normal_signals(self, value: bool) -> None:
|
||||
self._show_normal_signals = value
|
||||
|
||||
def _format_value(self, value: str):
|
||||
if self._decimal_places == 0:
|
||||
return value
|
||||
try:
|
||||
return f"{float(value):0.{self._decimal_places}f}"
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_display_value(self, value: str):
|
||||
"""Set the display to a given value, appending the units if specified"""
|
||||
self._display.setText(f"{self._format_value(value)}{self._units_string}")
|
||||
self._display.setToolTip("")
|
||||
|
||||
@property
|
||||
def _units_string(self):
|
||||
if self.custom_units or self._show_default_units:
|
||||
return f" {self.custom_units or self._default_units or ''}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def _default_units(self) -> str:
|
||||
return self._units
|
||||
|
||||
@property
|
||||
def _default_label(self) -> str:
|
||||
return (
|
||||
str(self._signal) if self._device == self._signal else f"{self._device} {self._signal}"
|
||||
)
|
||||
|
||||
def _update_label(self):
|
||||
self._label.setTitle(
|
||||
self._custom_label if self._custom_label else f"{self._default_label}:"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
w.setLayout(QVBoxLayout())
|
||||
w.layout().addWidget(
|
||||
SignalLabel(
|
||||
device="samx",
|
||||
signal="readback",
|
||||
custom_label="custom label:",
|
||||
custom_units=" m/s/s",
|
||||
show_select_button=False,
|
||||
)
|
||||
)
|
||||
w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True))
|
||||
l = SignalLabel()
|
||||
l.device = "bpm4i"
|
||||
l.signal = "bpm4i"
|
||||
w.layout().addWidget(l)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['signal_label.py']}
|
||||
@@ -1,43 +1,39 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.console.console import BECConsole
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECConsole' name='bec_console'>
|
||||
<widget class='SignalLabel' name='signal_label'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class SignalLabelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECConsole(parent)
|
||||
t = SignalLabel(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Console"
|
||||
return "BEC Utils"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECConsole.ICON_NAME)
|
||||
return designer_material_icon(SignalLabel.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_console"
|
||||
return "signal_label"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -49,10 +45,10 @@ class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECConsole"
|
||||
return "SignalLabel"
|
||||
|
||||
def toolTip(self):
|
||||
return "A terminal-like vt100 widget."
|
||||
return "Display the live value of any signal"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
from qtpy.QtWidgets import QColorDialog, QPushButton
|
||||
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
|
||||
@@ -12,6 +15,8 @@ class ColorButtonNative(BECWidget, QPushButton):
|
||||
to guarantee good readability.
|
||||
"""
|
||||
|
||||
color_changed = Signal(str)
|
||||
|
||||
RPC = False
|
||||
PLUGIN = True
|
||||
ICON_NAME = "colors"
|
||||
@@ -25,9 +30,10 @@ class ColorButtonNative(BECWidget, QPushButton):
|
||||
"""
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.set_color(color)
|
||||
self.clicked.connect(self._open_color_dialog)
|
||||
|
||||
@SafeSlot()
|
||||
def set_color(self, color):
|
||||
def set_color(self, color: str | QColor):
|
||||
"""Set the button's color and update its appearance.
|
||||
|
||||
Args:
|
||||
@@ -38,6 +44,7 @@ class ColorButtonNative(BECWidget, QPushButton):
|
||||
else:
|
||||
self._color = color
|
||||
self._update_appearance()
|
||||
self.color_changed.emit(self._color)
|
||||
|
||||
@SafeProperty("QColor")
|
||||
def color(self):
|
||||
@@ -56,3 +63,11 @@ class ColorButtonNative(BECWidget, QPushButton):
|
||||
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
|
||||
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
|
||||
self.setText(self._color)
|
||||
|
||||
@SafeSlot()
|
||||
def _open_color_dialog(self):
|
||||
"""Open a QColorDialog and apply the selected color."""
|
||||
current_color = QColor(self._color)
|
||||
chosen_color = QColorDialog.getColor(current_color, self, "Select Curve Color")
|
||||
if chosen_color.isValid():
|
||||
self.set_color(chosen_color)
|
||||
|
||||
@@ -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
|
||||
BIN
docs/user/widgets/signal_label/designer_screenshot.png
Normal file
BIN
docs/user/widgets/signal_label/designer_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
102
docs/user/widgets/signal_label/signal_label.md
Normal file
102
docs/user/widgets/signal_label/signal_label.md
Normal file
@@ -0,0 +1,102 @@
|
||||
(user.widgets.signal_label)=
|
||||
|
||||
# Signal Label widget
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`SignalLabel`](/api_reference/_autosummary/bec_widgets.cli.client.SignalLabel) displays the value of a signal from a device, with optional customization for labels, units, decimal formatting, and signal selection. It is designed for use in BEC (Beamline Experiment Control) GUIs to monitor values which beamline operators might want to keep an eye on, e.g. sample position, flux, hutch state...
|
||||
|
||||
## Key Features:
|
||||
- Display: Shows the current value of a device signal.
|
||||
- Custom Label/Units: Optionally override the default label and units.
|
||||
- Decimal Formatting: Control the number of decimal places shown.
|
||||
- Signal Selection: (Optional) Button to open a dialog for selecting a device and signal.
|
||||
- Live Updates: Subscribes to device updates and refreshes the display automatically.
|
||||
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - python
|
||||
|
||||
The `SignalLabel` widget can be used inside another widget to build an overall GUI display. For example, to create a display
|
||||
for the sample position like this:
|
||||
|
||||
|
||||
```{figure} ./test_screenshot.png
|
||||
```
|
||||
|
||||
You can simply add three of these signal displays as done here:
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
|
||||
|
||||
|
||||
class SamplePositionWidget(BECWidget, QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.setLayout(QVBoxLayout())
|
||||
self.samx_readback = SignalLabel(
|
||||
device="samx",
|
||||
signal="readback",
|
||||
custom_label="Sample X:",
|
||||
custom_units="mm",
|
||||
show_select_button=False,
|
||||
show_default_units=False,
|
||||
)
|
||||
self.samy_readback = SignalLabel(
|
||||
device="samy",
|
||||
signal="readback",
|
||||
custom_label="Sample Y:",
|
||||
custom_units="mm",
|
||||
show_select_button=False,
|
||||
show_default_units=False,
|
||||
)
|
||||
self.samz_readback = SignalLabel(
|
||||
device="samz",
|
||||
signal="readback",
|
||||
custom_label="Sample Z:",
|
||||
custom_units="mm",
|
||||
show_select_button=False,
|
||||
show_default_units=False,
|
||||
)
|
||||
self.layout().addWidget(self.samx_readback)
|
||||
self.layout().addWidget(self.samy_readback)
|
||||
self.layout().addWidget(self.samz_readback)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication()
|
||||
w = SamplePositionWidget()
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - BEC desginer
|
||||
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
|
||||
|
||||
```{figure} ./designer_screenshot.png
|
||||
```
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.TextBox.rst
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
docs/user/widgets/signal_label/test_screenshot.png
Normal file
BIN
docs/user/widgets/signal_label/test_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -175,6 +175,14 @@ Various buttons which manage the control of the BEC Queue.
|
||||
Choose individual device from current session.
|
||||
```
|
||||
|
||||
```{grid-item-card} Signal Label
|
||||
:link: user.widgets.signal_label
|
||||
:link-type: ref
|
||||
:img-top: ./signal_label/test_screenshot.png
|
||||
|
||||
Display the live value of a signal.
|
||||
```
|
||||
|
||||
```{grid-item-card} Signal Input Widgets
|
||||
:link: user.widgets.signal_input
|
||||
:link-type: ref
|
||||
@@ -289,5 +297,7 @@ lmfit_dialog/lmfit_dialog.md
|
||||
dap_combo_box/dap_combo_box.md
|
||||
games/games.md
|
||||
log_panel/log_panel.md
|
||||
signal_label/signal_label.md
|
||||
|
||||
|
||||
```
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.5.0"
|
||||
version = "2.12.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -21,7 +21,6 @@ dependencies = [
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"pyte", # needed for vt100 console
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -145,7 +145,14 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
||||
|
||||
# Check that the number of top level widgets is still the same. As the cleanup is done by the
|
||||
# qt event loop, we need to wait for the qtbot to finish the cleanup
|
||||
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
|
||||
try:
|
||||
qtbot.waitUntil(lambda: len(gui._server_registry) == top_level_widgets_count)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Widget {object_name} was not removed properly. The number of top level widgets "
|
||||
f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following "
|
||||
f"widgets are still registered: {list(gui._server_registry.keys())}."
|
||||
) from exc
|
||||
# Number of widgets with parent_id == None, should be 2
|
||||
widgets = [
|
||||
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)
|
||||
|
||||
94
tests/end-2-end/test_with_plugins_e2e.py
Normal file
94
tests/end-2-end/test_with_plugins_e2e.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from bec_testing_plugin.scans.metadata_schema.custom_test_scan_schema import CustomScanSchema
|
||||
except ImportError:
|
||||
pytest.skip(reason="Requires plugin repo!", allow_module_level=True)
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
87
tests/unit_tests/test_color_button_native.py
Normal file
87
tests/unit_tests/test_color_button_native.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QColorDialog
|
||||
|
||||
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
|
||||
ColorButtonNative,
|
||||
)
|
||||
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
def test_color_button_native(qtbot):
|
||||
cb = create_widget(qtbot, ColorButtonNative)
|
||||
|
||||
# Check if the instance is created successfully
|
||||
assert cb is not None
|
||||
|
||||
# Check if the button has a default color
|
||||
assert cb.color is not None
|
||||
|
||||
# Check if the button can change color
|
||||
new_color = QColor(255, 0, 0) # Red
|
||||
cb.set_color(new_color)
|
||||
assert cb.color == new_color.name()
|
||||
|
||||
|
||||
def test_color_dialog_applies_chosen_color(qtbot, monkeypatch):
|
||||
"""Clicking the button should open the dialog and apply the selected color."""
|
||||
cb = create_widget(qtbot, ColorButtonNative)
|
||||
chosen_color = QColor(0, 255, 0) # Green
|
||||
|
||||
# Force QColorDialog.getColor to return our chosen color
|
||||
monkeypatch.setattr(QColorDialog, "getColor", lambda *args, **kwargs: chosen_color)
|
||||
|
||||
# Expect the color_changed signal during the click
|
||||
with qtbot.waitSignal(cb.color_changed, timeout=1000):
|
||||
qtbot.mouseClick(cb, Qt.LeftButton)
|
||||
|
||||
assert cb.color == chosen_color.name()
|
||||
|
||||
|
||||
def test_color_dialog_cancel_keeps_color(qtbot, monkeypatch):
|
||||
"""If the dialog returns an invalid color, the button color should stay the same."""
|
||||
cb = create_widget(qtbot, ColorButtonNative)
|
||||
original_color = cb.color
|
||||
|
||||
# Simulate cancel: return an invalid QColor
|
||||
monkeypatch.setattr(QColorDialog, "getColor", lambda *args, **kwargs: QColor())
|
||||
|
||||
qtbot.mouseClick(cb, Qt.LeftButton)
|
||||
|
||||
# No signal emitted, color unchanged
|
||||
assert cb.color == original_color
|
||||
|
||||
|
||||
# Additional tests for color property getter/setter
|
||||
def test_color_property_getter_setter_hex(qtbot):
|
||||
"""Verify the color property works correctly with hex strings."""
|
||||
cb = create_widget(qtbot, ColorButtonNative)
|
||||
|
||||
# Confirm default value is a valid hex string
|
||||
default_color = cb.color
|
||||
assert (
|
||||
isinstance(default_color, str) and default_color.startswith("#") and len(default_color) == 7
|
||||
)
|
||||
|
||||
# Use property setter with a new hex color
|
||||
new_color_hex = "#123456"
|
||||
with qtbot.waitSignal(cb.color_changed, timeout=1000):
|
||||
cb.color = new_color_hex
|
||||
|
||||
# Getter should reflect the new value
|
||||
assert cb.color == new_color_hex
|
||||
# Button text should update as well
|
||||
assert cb.text() == new_color_hex
|
||||
|
||||
|
||||
def test_color_property_setter_qcolor(qtbot):
|
||||
"""Verify the color property accepts QColor and emits the signal."""
|
||||
cb = create_widget(qtbot, ColorButtonNative)
|
||||
q_color = QColor(200, 100, 50)
|
||||
|
||||
with qtbot.waitSignal(cb.color_changed, timeout=1000):
|
||||
cb.color = q_color
|
||||
|
||||
assert cb.color == q_color.name()
|
||||
assert cb.text() == q_color.name()
|
||||
@@ -1,65 +0,0 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from pygments.token import Token
|
||||
from qtpy.QtCore import QEventLoop
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.editors.console.console import BECConsole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot):
|
||||
apply_theme("light")
|
||||
console = BECConsole()
|
||||
console.set_cmd(sys.executable) # will launch Python interpreter
|
||||
console.set_prompt_tokens((Token.Prompt, ">>>"))
|
||||
qtbot.addWidget(console)
|
||||
console.show()
|
||||
qtbot.waitExposed(console)
|
||||
yield console
|
||||
console.terminate()
|
||||
|
||||
|
||||
def test_console_widget(console_widget, qtbot, tmp_path):
|
||||
def wait_prompt(command_to_execute=None, busy=False):
|
||||
signal_waiter = QEventLoop()
|
||||
|
||||
def exit_loop(idle):
|
||||
if busy and not idle:
|
||||
signal_waiter.quit()
|
||||
elif not busy and idle:
|
||||
signal_waiter.quit()
|
||||
|
||||
console_widget.prompt.connect(exit_loop)
|
||||
if command_to_execute:
|
||||
if callable(command_to_execute):
|
||||
command_to_execute()
|
||||
else:
|
||||
console_widget.execute_command(command_to_execute)
|
||||
signal_waiter.exec_()
|
||||
|
||||
console_widget.start()
|
||||
wait_prompt()
|
||||
|
||||
# use console to write something to a tmp file
|
||||
tmp_filename = str(tmp_path / "console_test.txt")
|
||||
wait_prompt(f"f = open('{tmp_filename}', 'wt'); f.write('HELLO CONSOLE'); f.close()")
|
||||
# check the code has been executed by console, by checking the tmp file contents
|
||||
with open(tmp_filename, "rt") as f:
|
||||
assert f.read() == "HELLO CONSOLE"
|
||||
|
||||
# execute a sleep
|
||||
t0 = time.perf_counter()
|
||||
wait_prompt("import time; time.sleep(1)")
|
||||
assert time.perf_counter() - t0 >= 1
|
||||
|
||||
# test ctrl-c
|
||||
t0 = time.perf_counter()
|
||||
wait_prompt("time.sleep(5)", busy=True)
|
||||
wait_prompt(console_widget.send_ctrl_c)
|
||||
assert (
|
||||
time.perf_counter() - t0 < 1
|
||||
) # in reality it will be almost immediate, but ok we can say less than 1 second compared to 5
|
||||
@@ -29,7 +29,6 @@ def image_widget_with_crosshair(qtbot):
|
||||
|
||||
image_item = pg.ImageItem()
|
||||
image_item.setImage(np.random.rand(100, 100))
|
||||
image_item.config = type("obj", (object,), {"monitor": "test"})
|
||||
|
||||
widget.addItem(image_item)
|
||||
plot_item = widget.getPlotItem()
|
||||
@@ -99,6 +98,7 @@ def test_mouse_moved_signals_outside(plot_widget_with_crosshair):
|
||||
|
||||
def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
image_item = plot_item.items[0]
|
||||
|
||||
emitted_values_2D = []
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
assert emitted_values_2D == [("test", 21, 55)]
|
||||
assert emitted_values_2D == [(str(id(image_item)), 21, 55)]
|
||||
|
||||
|
||||
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
|
||||
@@ -236,7 +236,7 @@ def test_update_coord_label_1D(plot_widget_with_crosshair):
|
||||
# Provide a test position
|
||||
pos = (10, 20)
|
||||
crosshair.update_coord_label(pos)
|
||||
expected_text = f"({10:.3g}, {20:.3g})"
|
||||
expected_text = f"({10:.3f}, {20:.3f})"
|
||||
# Verify that the coordinate label shows only the 1D coordinates (no intensity line)
|
||||
assert crosshair.coord_label.toPlainText() == expected_text
|
||||
label_pos = crosshair.coord_label.pos()
|
||||
@@ -260,10 +260,54 @@ def test_update_coord_label_2D(image_widget_with_crosshair):
|
||||
ix = int(np.clip(0.5, 0, known_image.shape[0] - 1)) # 0
|
||||
iy = int(np.clip(1.2, 0, known_image.shape[1] - 1)) # 1
|
||||
intensity = known_image[ix, iy] # Expected: 20
|
||||
expected_text = f"({0.5:.3g}, {1.2:.3g})\nIntensity: {intensity:.3g}"
|
||||
expected_text = f"({0.5:.3f}, {1.2:.3f})\nIntensity: {intensity:.3f}"
|
||||
|
||||
assert crosshair.coord_label.toPlainText() == expected_text
|
||||
label_pos = crosshair.coord_label.pos()
|
||||
assert np.isclose(label_pos.x(), 0.5)
|
||||
assert np.isclose(label_pos.y(), 1.2)
|
||||
assert crosshair.coord_label.isVisible()
|
||||
|
||||
|
||||
def test_crosshair_precision_properties(plot_widget_with_crosshair):
|
||||
"""
|
||||
Ensure Crosshair.precision and Crosshair.min_precision behave correctly
|
||||
and that _current_precision() reflects changes immediately.
|
||||
"""
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
assert crosshair.precision == 3
|
||||
assert crosshair._current_precision() == 3
|
||||
|
||||
crosshair.precision = None
|
||||
plot_item.vb.setXRange(0, 1_000, padding=0)
|
||||
plot_item.vb.setYRange(0, 1_000, padding=0)
|
||||
assert crosshair._current_precision() == crosshair.min_precision == 2 # default floor
|
||||
|
||||
crosshair.min_precision = 5
|
||||
assert crosshair._current_precision() == 5
|
||||
|
||||
crosshair.precision = 1
|
||||
assert crosshair._current_precision() == 1
|
||||
|
||||
|
||||
def test_crosshair_precision_properties_image(image_widget_with_crosshair):
|
||||
"""
|
||||
The same precision/min_precision behaviour must apply for crosshairs attached
|
||||
to ImageItem-based plots.
|
||||
"""
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
|
||||
assert crosshair.precision == 3
|
||||
assert crosshair._current_precision() == 3
|
||||
|
||||
crosshair.precision = None
|
||||
plot_item.vb.setXRange(0, 1_000, padding=0)
|
||||
plot_item.vb.setYRange(0, 1_000, padding=0)
|
||||
assert crosshair._current_precision() == crosshair.min_precision == 2
|
||||
|
||||
crosshair.min_precision = 6
|
||||
assert crosshair._current_precision() == 6
|
||||
|
||||
crosshair.precision = 2
|
||||
assert crosshair._current_precision() == 2
|
||||
|
||||
@@ -5,13 +5,20 @@ import pytest
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
|
||||
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItemForm
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QListWidgetItem
|
||||
|
||||
from bec_widgets.widgets.services.device_browser import DeviceItem
|
||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=missing-function-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -30,22 +37,24 @@ def test_device_browser_init_with_devices(device_browser):
|
||||
assert device_list.count() == len(device_browser.dev)
|
||||
|
||||
|
||||
def test_device_browser_filtering(qtbot, device_browser):
|
||||
@pytest.mark.parametrize(
|
||||
["search_term", "expected_num_visible"],
|
||||
[("sam", 3), ("nonexistent", 0), ("", -1), (r"(\)", -1)],
|
||||
)
|
||||
def test_device_browser_filtering(
|
||||
qtbot, device_browser, search_term: str, expected_num_visible: int
|
||||
):
|
||||
"""
|
||||
Test that the device browser is able to filter the device list.
|
||||
"""
|
||||
device_list = device_browser.ui.device_list
|
||||
device_browser.ui.filter_input.setText("sam")
|
||||
qtbot.wait(1000)
|
||||
assert device_list.count() == 3
|
||||
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
|
||||
|
||||
device_browser.ui.filter_input.setText("nonexistent")
|
||||
qtbot.wait(1000)
|
||||
assert device_list.count() == 0
|
||||
def num_visible(item_dict):
|
||||
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
|
||||
|
||||
device_browser.ui.filter_input.setText("")
|
||||
qtbot.wait(1000)
|
||||
assert device_list.count() == len(device_browser.dev)
|
||||
device_browser.ui.filter_input.setText(search_term)
|
||||
qtbot.wait(100)
|
||||
assert num_visible(device_browser._device_items) == expected
|
||||
|
||||
|
||||
def test_device_item_mouse_press_event(device_browser, qtbot):
|
||||
@@ -55,7 +64,38 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
|
||||
# Simulate a left mouse press event on the device item
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
qtbot.mouseClick(widget.label, Qt.MouseButton.LeftButton)
|
||||
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
|
||||
|
||||
|
||||
def test_update_event_captured(device_browser, qtbot):
|
||||
device_browser.update_device_list = mock.MagicMock()
|
||||
device_browser.update_device_list.assert_not_called()
|
||||
device_browser.on_device_update("remove", {})
|
||||
device_browser.update_device_list.assert_called_once()
|
||||
device_browser.on_device_update("", {})
|
||||
|
||||
|
||||
def test_device_item_expansion(device_browser, qtbot):
|
||||
"""
|
||||
Test that the form is displayed when the item is expanded, and that the expansion is triggered
|
||||
by clicking on the expansion button, the title, or the device icon
|
||||
"""
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
form = widget._contents.layout().itemAt(0).widget()
|
||||
qtbot.waitUntil(lambda: isinstance(form, DeviceItemForm), timeout=500)
|
||||
assert widget.expanded
|
||||
assert (name_field := form.widget_dict.get("name")) is not None
|
||||
assert name_field.getValue() == "samx"
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
assert not widget.expanded
|
||||
|
||||
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
|
||||
qtbot.waitUntil(lambda: widget.expanded, timeout=500)
|
||||
|
||||
qtbot.mouseClick(widget._title_icon, Qt.MouseButton.LeftButton)
|
||||
qtbot.waitUntil(lambda: not widget.expanded, timeout=500)
|
||||
|
||||
|
||||
def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qtbot):
|
||||
@@ -67,7 +107,7 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
|
||||
device_name = widget.device
|
||||
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
|
||||
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
|
||||
qtbot.mousePress(widget.label, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
|
||||
qtbot.mousePress(widget._title, Qt.MouseButton.LeftButton, pos=QPoint(0, 0))
|
||||
qtbot.mouseMove(widget, pos=QPoint(10, 10))
|
||||
qtbot.mouseRelease(widget, Qt.MouseButton.LeftButton)
|
||||
mock_set_mimedata.assert_called_once()
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -61,7 +67,7 @@ def test_device_signal_combo(qtbot, mocked_client):
|
||||
def test_device_signal_base_init(device_signal_base):
|
||||
"""Test if the DeviceSignalInputBase is initialized correctly"""
|
||||
assert device_signal_base._device is None
|
||||
assert device_signal_base._signal_filter == []
|
||||
assert device_signal_base._signal_filter == set()
|
||||
assert device_signal_base._signals == []
|
||||
assert device_signal_base._hinted_signals == []
|
||||
assert device_signal_base._normal_signals == []
|
||||
@@ -70,12 +76,22 @@ def test_device_signal_base_init(device_signal_base):
|
||||
|
||||
def test_device_signal_qproperties(device_signal_base):
|
||||
"""Test if the DeviceSignalInputBase has the correct QProperties"""
|
||||
assert device_signal_base._signal_filter == set()
|
||||
device_signal_base.include_config_signals = False
|
||||
device_signal_base.include_normal_signals = False
|
||||
assert device_signal_base._signal_filter == set()
|
||||
device_signal_base.include_config_signals = True
|
||||
assert device_signal_base._signal_filter == [Kind.config]
|
||||
assert device_signal_base._signal_filter == {Kind.config}
|
||||
device_signal_base.include_normal_signals = True
|
||||
assert device_signal_base._signal_filter == [Kind.config, Kind.normal]
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
|
||||
device_signal_base.include_hinted_signals = True
|
||||
assert device_signal_base._signal_filter == [Kind.config, Kind.normal, Kind.hinted]
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
|
||||
device_signal_base.include_hinted_signals = True
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
|
||||
device_signal_base.include_hinted_signals = True
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
|
||||
device_signal_base.include_hinted_signals = False
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
|
||||
|
||||
|
||||
def test_device_signal_set_device(device_signal_base):
|
||||
@@ -107,9 +123,17 @@ 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):
|
||||
def test_signal_lineedit(device_signal_line_edit):
|
||||
"""Test the signal_combobox"""
|
||||
|
||||
assert device_signal_line_edit._signals == []
|
||||
@@ -119,3 +143,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user